add sandbox observability contracts
This commit is contained in:
parent
e9ef178b15
commit
a86e1ee8c7
6 changed files with 131 additions and 0 deletions
|
|
@ -20,6 +20,14 @@ class NoopMetrics:
|
|||
) -> None:
|
||||
return None
|
||||
|
||||
def set(
|
||||
self,
|
||||
name: str,
|
||||
value: int | float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class NoopSpan:
|
||||
def set_attribute(self, name: str, value: AttrValue) -> None:
|
||||
|
|
|
|||
|
|
@ -5,12 +5,21 @@ from opentelemetry.metrics import Counter, Histogram, Meter
|
|||
from usecase.interface import Attrs
|
||||
|
||||
|
||||
class _GaugeAdapter:
|
||||
def __init__(self, gauge: object) -> None:
|
||||
self._gauge = gauge
|
||||
|
||||
def set(self, value: int | float, attributes: object = None) -> None:
|
||||
getattr(self._gauge, 'set')(value, attributes=attributes)
|
||||
|
||||
|
||||
class OtelMetrics:
|
||||
def __init__(self, meter: Meter) -> None:
|
||||
self._meter = meter
|
||||
self._lock = Lock()
|
||||
self._counters: dict[str, Counter] = {}
|
||||
self._histograms: dict[str, Histogram] = {}
|
||||
self._gauges: dict[str, _GaugeAdapter] = {}
|
||||
|
||||
def increment(
|
||||
self,
|
||||
|
|
@ -34,6 +43,17 @@ class OtelMetrics:
|
|||
attributes=None if attrs is None else dict(attrs),
|
||||
)
|
||||
|
||||
def set(
|
||||
self,
|
||||
name: str,
|
||||
value: int | float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
self._gauge(name).set(
|
||||
value,
|
||||
attributes=None if attrs is None else dict(attrs),
|
||||
)
|
||||
|
||||
def _counter(self, name: str) -> Counter:
|
||||
counter = self._counters.get(name)
|
||||
if counter is not None:
|
||||
|
|
@ -57,3 +77,15 @@ class OtelMetrics:
|
|||
histogram = self._meter.create_histogram(name)
|
||||
self._histograms[name] = histogram
|
||||
return histogram
|
||||
|
||||
def _gauge(self, name: str) -> _GaugeAdapter:
|
||||
gauge = self._gauges.get(name)
|
||||
if gauge is not None:
|
||||
return gauge
|
||||
|
||||
with self._lock:
|
||||
gauge = self._gauges.get(name)
|
||||
if gauge is None:
|
||||
gauge = _GaugeAdapter(self._meter.create_gauge(name))
|
||||
self._gauges[name] = gauge
|
||||
return gauge
|
||||
|
|
|
|||
18
docs/008-sandbox-lifecycle-observability.md
Normal file
18
docs/008-sandbox-lifecycle-observability.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 008 Sandbox lifecycle observability
|
||||
|
||||
## Context
|
||||
- FR-034 требует метрики по active sandbox, startup latency и cleanup
|
||||
- Issue #11 требует трассировку sandbox usecase и Docker adapter steps
|
||||
- Inner layers должны знать только observability ports
|
||||
|
||||
## Decision
|
||||
- Usecase sandbox lifecycle использует только `Logger`, `Metrics`, `Tracer`
|
||||
- `Metrics` получает `set(...)` для current-state signals
|
||||
- `sandbox.active.count` считается из session registry через `count_active()`
|
||||
- M19 добавляет только contracts и adapter support для будущих lifecycle signals
|
||||
- M20 и M21 отдельно добавят spans и runtime metrics в usecase и Docker adapter
|
||||
|
||||
## Consequences
|
||||
- OTel gauge остается в outer adapter, не протекает во внутренние слои
|
||||
- Active sandbox count синхронизируется после create, cleanup и reconciliation
|
||||
- Tests могут проверять observability через fake ports без реального OTel backend
|
||||
|
|
@ -29,6 +29,10 @@ class InMemorySandboxSessionRepository(SandboxSessionRepository):
|
|||
if session.expires_at <= now
|
||||
]
|
||||
|
||||
def count_active(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._sessions_by_chat_id)
|
||||
|
||||
def save(self, session: SandboxSession) -> None:
|
||||
with self._lock:
|
||||
self._sessions_by_chat_id[session.chat_id] = session
|
||||
|
|
|
|||
60
tasks.md
60
tasks.md
|
|
@ -227,3 +227,63 @@
|
|||
- Файлы: `domain/sandbox.py`, `usecase/interface.py`, `usecase/sandbox.py`, `repository/sandbox_session.py`, `adapter/http/fastapi/*`, `adapter/docker/runtime.py`, `adapter/di/container.py`, `test/*`
|
||||
- Решение: HTTP boundary принимает/возвращает UUID, usecase и repository работают с UUID objects, Docker labels продолжают сериализоваться в строки через `str(uuid)`
|
||||
- Критерии приемки: внутри sandbox flow `chat_id` и `session_id` больше не строки; `container_id` остается `str`; pydantic корректно сериализует UUID в response; `make pre-commit` проходит
|
||||
|
||||
## Follow-up после issue #11 observability
|
||||
|
||||
### M19. ADR и observability contracts для sandbox lifecycle
|
||||
|
||||
- Исполнитель: `primary-agent`
|
||||
- Статус: completed
|
||||
- Зависимости: `M18`
|
||||
- Commit required: yes
|
||||
- Commit message: `add sandbox observability contracts`
|
||||
- Scope: зафиксировать sandbox lifecycle observability policy в ADR-lite и подготовить минимальные контракты для traces и current-state metrics без нарушения clean architecture
|
||||
- Файлы: `docs/008-sandbox-lifecycle-observability.md`, `usecase/interface.py`, `repository/sandbox_session.py`, `adapter/otel/metrics.py`, `adapter/observability/noop.py`
|
||||
- Решение: добавить в `Metrics` порт операцию `set(...)` для gauge-like current-state сигналов; добавить в `SandboxSessionRepository` `count_active()` как источник truth для `sandbox.active.count`
|
||||
- Критерии приемки: ADR занимает 10-20 строк; inner layers по-прежнему знают только порты `Logger`/`Metrics`/`Tracer`; current-state метрика активных sandbox выражается без OTel imports во внутреннем слое
|
||||
|
||||
### M20. Трейсы и метрики в sandbox usecases
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: pending
|
||||
- Зависимости: `M19`
|
||||
- Commit required: yes
|
||||
- Commit message: `instrument sandbox usecases`
|
||||
- Scope: добавить spans и ключевые lifecycle metrics в `CreateSandbox` и `CleanupExpiredSandboxes`
|
||||
- Файлы: `usecase/sandbox.py`, `adapter/di/container.py`, при необходимости тесты в `test/*`
|
||||
- Решение: usecase получает `Metrics` и `Tracer` через конструктор; `CreateSandbox` и `CleanupExpiredSandboxes` публикуют `sandbox.create.total`, `sandbox.cleanup.total`, `sandbox.cleanup.error.total` и обновляют `sandbox.active.count` после мутаций registry
|
||||
- Критерии приемки: есть spans `usecase.create_sandbox` и `usecase.cleanup_expired_sandboxes`; span attrs и metric attrs включают ключевые lifecycle identifiers/result fields; reuse/replace/cleanup paths наблюдаемы без OTel imports в usecase
|
||||
|
||||
### M21. Трейсы и runtime metrics в Docker adapter и reconciliation
|
||||
|
||||
- Субагент: `feature-developer`
|
||||
- Статус: pending
|
||||
- Зависимости: `M19`
|
||||
- Commit required: yes
|
||||
- Commit message: `instrument sandbox docker runtime`
|
||||
- Scope: добавить observability в `DockerSandboxRuntime` и reconciliation path для Docker operations и current-state sync
|
||||
- Файлы: `adapter/docker/runtime.py`, `adapter/sandbox/reconciliation.py`, `adapter/di/container.py`, при необходимости тесты в `test/*`
|
||||
- Решение: `DockerSandboxRuntime` получает `Metrics` и `Tracer`; create/stop/list публикуют duration histograms `sandbox.runtime.create.duration_ms`, `sandbox.runtime.stop.duration_ms`, `sandbox.runtime.list_active.duration_ms`, error counter `sandbox.runtime.error.total` и span attrs по chat/session/container; reconciliation обновляет `sandbox.active.count` по registry snapshot
|
||||
- Критерии приемки: Docker adapter остается во внешнем слое; ошибки Docker операций отражаются в spans и metrics; после startup reconciliation current-state метрика активных sandbox синхронизирована с registry
|
||||
|
||||
### M22. Тесты на sandbox observability
|
||||
|
||||
- Субагент: `test-engineer`
|
||||
- Статус: pending
|
||||
- Зависимости: `M20`, `M21`
|
||||
- Commit required: yes
|
||||
- Commit message: `add sandbox observability tests`
|
||||
- Scope: покрыть regression tests новую observability policy без реального OTel backend
|
||||
- Файлы: `test/test_sandbox_usecase.py`, `test/test_docker_runtime.py`, при необходимости новые focused tests в `test/*`
|
||||
- Решение: использовать типизированные fake metrics/tracer implementations и проверить names/attrs ключевых spans и metrics на create/reuse/replace/cleanup/runtime paths
|
||||
- Критерии приемки: тесты подтверждают spans и metrics на usecase и adapter paths; constructor wiring обновлен без mypy regressions; `make typecheck` и релевантный `pytest` проходят
|
||||
|
||||
### M23. Boundary review для sandbox observability
|
||||
|
||||
- Субагент: `code-reviewer`
|
||||
- Статус: pending
|
||||
- Зависимости: `M22`
|
||||
- Commit required: no
|
||||
- Scope: проверить, что observability изменения закрывают issue #11 и FR-034 без нарушения clean architecture
|
||||
- Файлы: весь измененный код после `M19`-`M22`
|
||||
- Критерии приемки: inner layers не импортируют OTel; Docker-specific tracing остается в `adapter/docker/`; current-state и duration metrics достаточно покрывают sandbox lifecycle; замечания сведены к minor или отсутствуют
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class SandboxSessionRepository(Protocol):
|
|||
|
||||
def list_expired(self, now: datetime) -> list[SandboxSession]: ...
|
||||
|
||||
def count_active(self) -> int: ...
|
||||
|
||||
def save(self, session: SandboxSession) -> None: ...
|
||||
|
||||
def delete(self, session_id: UUID) -> None: ...
|
||||
|
|
@ -86,6 +88,13 @@ class Metrics(Protocol):
|
|||
attrs: Attrs | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def set(
|
||||
self,
|
||||
name: str,
|
||||
value: int | float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class Span(Protocol):
|
||||
def set_attribute(self, name: str, value: AttrValue) -> None: ...
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue