add sandbox observability contracts

This commit is contained in:
Azamat 2026-04-03 00:37:35 +03:00
parent e9ef178b15
commit a86e1ee8c7
6 changed files with 131 additions and 0 deletions

View file

@ -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:

View file

@ -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

View 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

View file

@ -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

View file

@ -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 или отсутствуют

View file

@ -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: ...