instrument sandbox docker runtime
This commit is contained in:
parent
4cdf6e45de
commit
8d3a080d45
6 changed files with 411 additions and 73 deletions
|
|
@ -7,6 +7,7 @@ from docker import DockerClient
|
|||
from fastapi import FastAPI
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
import adapter.di.container as container_module
|
||||
from adapter.config.model import (
|
||||
AppConfig,
|
||||
AppSectionConfig,
|
||||
|
|
@ -20,6 +21,7 @@ from adapter.config.model import (
|
|||
TracingConfig,
|
||||
)
|
||||
from adapter.di.container import AppContainer, AppRepositories, AppUsecases
|
||||
from adapter.docker.runtime import DockerSandboxRuntime
|
||||
from adapter.http.fastapi import app as app_module
|
||||
from adapter.observability.noop import NoopMetrics, NoopTracer
|
||||
from adapter.observability.runtime import ObservabilityRuntime
|
||||
|
|
@ -80,7 +82,8 @@ class FakeCleanupExpiredSandboxes(CleanupExpiredSandboxes):
|
|||
|
||||
|
||||
class FakeDockerClient(DockerClient):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = base_url
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
|
|
@ -104,6 +107,79 @@ class FakeClock:
|
|||
return self._now
|
||||
|
||||
|
||||
class RecordingMetrics:
|
||||
def __init__(self) -> None:
|
||||
self.increment_calls: list[tuple[str, int, Attrs | None]] = []
|
||||
self.record_calls: list[tuple[str, float, Attrs | None]] = []
|
||||
self.set_calls: list[tuple[str, int | float, Attrs | None]] = []
|
||||
|
||||
def increment(
|
||||
self,
|
||||
name: str,
|
||||
value: int = 1,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
self.increment_calls.append((name, value, attrs))
|
||||
|
||||
def record(
|
||||
self,
|
||||
name: str,
|
||||
value: float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
self.record_calls.append((name, value, attrs))
|
||||
|
||||
def set(
|
||||
self,
|
||||
name: str,
|
||||
value: int | float,
|
||||
attrs: Attrs | None = None,
|
||||
) -> None:
|
||||
self.set_calls.append((name, value, attrs))
|
||||
|
||||
|
||||
class RecordingSpan:
|
||||
def __init__(self) -> None:
|
||||
self.attrs: dict[str, str | int | float | bool] = {}
|
||||
self.errors: list[Exception] = []
|
||||
|
||||
def set_attribute(self, name: str, value: str | int | float | bool) -> None:
|
||||
self.attrs[name] = value
|
||||
|
||||
def record_error(self, error: Exception) -> None:
|
||||
self.errors.append(error)
|
||||
|
||||
|
||||
class RecordingSpanContext:
|
||||
def __init__(self, span: RecordingSpan) -> None:
|
||||
self._span = span
|
||||
|
||||
def __enter__(self) -> RecordingSpan:
|
||||
return self._span
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: object,
|
||||
) -> bool | None:
|
||||
return None
|
||||
|
||||
|
||||
class RecordingTracer:
|
||||
def __init__(self) -> None:
|
||||
self.spans: list[tuple[str, Attrs | None, RecordingSpan]] = []
|
||||
|
||||
def start_span(
|
||||
self,
|
||||
name: str,
|
||||
attrs: Attrs | None = None,
|
||||
) -> RecordingSpanContext:
|
||||
span = RecordingSpan()
|
||||
self.spans.append((name, attrs, span))
|
||||
return RecordingSpanContext(span)
|
||||
|
||||
|
||||
class FakeLifecycleRuntime:
|
||||
def __init__(self, sessions: list[SandboxSession]) -> None:
|
||||
self._sessions = list(sessions)
|
||||
|
|
@ -142,6 +218,26 @@ class FakeLifecycleRuntime:
|
|||
self.stop_calls.append(container_id)
|
||||
|
||||
|
||||
class FixedSandboxState:
|
||||
def __init__(self, sessions: list[SandboxSession]) -> None:
|
||||
self._sessions = list(sessions)
|
||||
|
||||
def list_active_sessions(self) -> list[SandboxSession]:
|
||||
return list(self._sessions)
|
||||
|
||||
|
||||
class CountingRegistry:
|
||||
def __init__(self, count_active_result: int) -> None:
|
||||
self._count_active_result = count_active_result
|
||||
self.replaced_sessions: list[SandboxSession] = []
|
||||
|
||||
def replace_all(self, sessions: list[SandboxSession]) -> None:
|
||||
self.replaced_sessions = list(sessions)
|
||||
|
||||
def count_active(self) -> int:
|
||||
return self._count_active_result
|
||||
|
||||
|
||||
def build_config() -> AppConfig:
|
||||
return AppConfig(
|
||||
app=AppSectionConfig(name='master', env='test'),
|
||||
|
|
@ -198,6 +294,8 @@ def build_container(
|
|||
state_source=EmptySandboxState(),
|
||||
registry=repositories.sandbox_session,
|
||||
logger=logger,
|
||||
metrics=observability.metrics,
|
||||
tracer=observability.tracer,
|
||||
)
|
||||
usecases = AppUsecases(
|
||||
create_sandbox=create_sandbox_usecase,
|
||||
|
|
@ -494,6 +592,8 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
|
|||
state_source=runtime,
|
||||
registry=repository,
|
||||
logger=logger,
|
||||
metrics=observability.metrics,
|
||||
tracer=observability.tracer,
|
||||
)
|
||||
usecases = AppUsecases(
|
||||
create_sandbox=CreateSandbox(
|
||||
|
|
@ -586,3 +686,76 @@ def test_removed_user_endpoint_returns_not_found(monkeypatch) -> None:
|
|||
assert status_code == 404
|
||||
assert response == {'detail': 'Not Found'}
|
||||
assert docker_client.close_calls == 1
|
||||
|
||||
|
||||
def test_reconciliation_uses_registry_backed_active_count_metric() -> None:
|
||||
logger = FakeLogger()
|
||||
metrics = RecordingMetrics()
|
||||
tracer = RecordingTracer()
|
||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||
session = SandboxSession(
|
||||
session_id=SESSION_ID,
|
||||
chat_id=CHAT_ID,
|
||||
container_id='container-123',
|
||||
status=SandboxStatus.RUNNING,
|
||||
created_at=created_at,
|
||||
expires_at=created_at + timedelta(minutes=5),
|
||||
)
|
||||
registry = CountingRegistry(count_active_result=7)
|
||||
reconciler = SandboxSessionReconciler(
|
||||
state_source=FixedSandboxState([session]),
|
||||
registry=registry,
|
||||
logger=logger,
|
||||
metrics=metrics,
|
||||
tracer=tracer,
|
||||
)
|
||||
|
||||
sessions = reconciler.execute()
|
||||
|
||||
assert sessions == [session]
|
||||
assert registry.replaced_sessions == [session]
|
||||
assert metrics.set_calls == [('sandbox.active.count', 7, None)]
|
||||
assert tracer.spans[0][0] == 'adapter.sandbox.reconcile_sessions'
|
||||
assert tracer.spans[0][2].attrs['sandbox.active_count'] == 7
|
||||
|
||||
|
||||
def test_build_container_wires_observability_into_runtime_and_reconciler(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
logger = FakeLogger()
|
||||
metrics = RecordingMetrics()
|
||||
tracer = RecordingTracer()
|
||||
observability = ObservabilityRuntime(
|
||||
logger=logger,
|
||||
metrics=metrics,
|
||||
tracer=tracer,
|
||||
)
|
||||
docker_client = FakeDockerClient()
|
||||
monkeypatch.setattr(
|
||||
container_module, 'build_observability', lambda config: observability
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
container_module.docker,
|
||||
'DockerClient',
|
||||
lambda base_url: docker_client,
|
||||
)
|
||||
|
||||
container = container_module.build_container(config=build_config())
|
||||
|
||||
runtime = container.sandbox_reconciler.state_source
|
||||
assert isinstance(runtime, DockerSandboxRuntime)
|
||||
assert runtime._metrics is metrics
|
||||
assert runtime._tracer is tracer
|
||||
assert container.sandbox_reconciler.metrics is metrics
|
||||
assert container.sandbox_reconciler.tracer is tracer
|
||||
assert container.usecases.create_sandbox._runtime is runtime
|
||||
assert container.usecases.create_sandbox._metrics is metrics
|
||||
assert container.usecases.create_sandbox._tracer is tracer
|
||||
assert container.usecases.cleanup_expired_sandboxes._runtime is runtime
|
||||
assert container.usecases.cleanup_expired_sandboxes._metrics is metrics
|
||||
assert container.usecases.cleanup_expired_sandboxes._tracer is tracer
|
||||
assert container._docker_client is docker_client
|
||||
|
||||
container.shutdown()
|
||||
|
||||
assert docker_client.close_calls == 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue