From a86e1ee8c706f68c25c9f09ebdea600c4ed179d1 Mon Sep 17 00:00:00 2001 From: Azamat Date: Fri, 3 Apr 2026 00:37:35 +0300 Subject: [PATCH] add sandbox observability contracts --- adapter/observability/noop.py | 8 +++ adapter/otel/metrics.py | 32 +++++++++++ docs/008-sandbox-lifecycle-observability.md | 18 +++++++ repository/sandbox_session.py | 4 ++ tasks.md | 60 +++++++++++++++++++++ usecase/interface.py | 9 ++++ 6 files changed, 131 insertions(+) create mode 100644 docs/008-sandbox-lifecycle-observability.md diff --git a/adapter/observability/noop.py b/adapter/observability/noop.py index fe7d190..7027d41 100644 --- a/adapter/observability/noop.py +++ b/adapter/observability/noop.py @@ -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: diff --git a/adapter/otel/metrics.py b/adapter/otel/metrics.py index 48d1278..ed9abe6 100644 --- a/adapter/otel/metrics.py +++ b/adapter/otel/metrics.py @@ -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 diff --git a/docs/008-sandbox-lifecycle-observability.md b/docs/008-sandbox-lifecycle-observability.md new file mode 100644 index 0000000..f56dc10 --- /dev/null +++ b/docs/008-sandbox-lifecycle-observability.md @@ -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 diff --git a/repository/sandbox_session.py b/repository/sandbox_session.py index 893ec65..bb680d2 100644 --- a/repository/sandbox_session.py +++ b/repository/sandbox_session.py @@ -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 diff --git a/tasks.md b/tasks.md index 494c655..d24657c 100644 --- a/tasks.md +++ b/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 или отсутствуют diff --git a/usecase/interface.py b/usecase/interface.py index 15c581a..69876e6 100644 --- a/usecase/interface.py +++ b/usecase/interface.py @@ -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: ...