multi-agent-lab / docs /adr /0024-observability.md
agharsallah
feat(observability): add OpenTelemetry logging/tracing/metrics foundation
3f7b296
|
Raw
History Blame Contribute Delete
4.24 kB
# ADR-0024: Observability β€” Structured Logging, Tracing, In-App Monitoring
## Status
Accepted
## Context
The engine had no cohesive observability. Each module made its own
`logging.getLogger(__name__)` with almost no calls (`conductor.py`, `memory.py`),
and the only structured output was the dependency-free JSON formatter for the
vLLM subprocess (`modal/vllm_logging.py`). There was no way to see, in one place,
**the prompts passed to each model and the memory each agent had access to** β€” the
two things that matter most when debugging a multi-agent run β€” nor any traces or
metrics across the api-call β†’ inference β†’ memory β†’ core-loop path.
We want a *complete*, modular log of the application that foregrounds **agent
behaviour, data, and LLM/API calls**, readable both in the terminal and live in
the Gradio app, leveraging OpenTelemetry for logging, tracing, and basic
monitoring.
## Decision
Introduce a single `src/observability/` package β€” a thin facade over OpenTelemetry
plus an in-memory store β€” that every layer imports as `from src import
observability as obs`.
* **One stable facade.** `configure()`, `get_logger()`, `log(event, **fields)`,
`span(name, **attrs)`, `add_span_attrs()`, `incr()/observe()` + named helpers
(`record_llm_call`, `record_agent_turn`, `record_governor_trip`),
`bind()/set_context()/current_context()`, and `telemetry_store()`. Call sites
never touch the OTEL SDK directly, so instrumentation stays a one-liner and the
wiring lives in one module.
* **Correlation by context, not by parameter.** Run / turn / agent are carried in
`contextvars` and stamped onto every log record and span automatically. The
conductor binds the run and turn; agents bind their name. A single `llm.call`
line therefore says which agent, which turn, which run, with no threading.
* **Real OTEL tracing, self-contained backend.** A real `TracerProvider` makes
spans nest by context: `run β†’ turn β†’ agent.turn β†’ {memory.recall β†’
memory.index.search} + {llm.call | llm.structured} + tool.call`. Finished spans
and all log records flow into bounded in-memory ring buffers
(`TelemetryStore`); a console exporter can also print spans. No Jaeger /
Prometheus / Grafana to deploy β€” the whole monitoring story lives in-process and
in the live demo. Metrics (LLM calls, tokens, cost, agent-turn latency, governor
trips) are in-process counters/observations feeding the UI charts.
* **In-app surface.** A Gradio "Telemetry" tab reads from `telemetry_store()`: a
filterable structured-log feed, metric charts, and a per-turn trace/timeline
where selecting a span reveals the actual prompt + memory the agent saw.
* **Env-driven, no secrets.** `MAL_LOG_LEVEL` (DEBUG surfaces full prompts +
memory), `MAL_LOG_FORMAT` (`text`|`json`), `MAL_TRACING`
(`off`|`console`|`memory`|`both`, default `memory`). API keys are never logged
or attached as span attributes; full prompts/memory are captured at DEBUG and
truncated in stored snapshots for the UI.
OpenTelemetry (`opentelemetry-api`, `-sdk`, `-semantic-conventions`) is a
first-class core dependency. LLM spans use GenAI semantic-convention attributes
(`gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens` /
`output_tokens`) alongside engine-specific ones (`llm.cost_usd`, `llm.prompt`,
`llm.completion`, `llm.reasoning`).
## Consequences
* Any module gets full structured logging, tracing, and metrics by importing the
facade β€” instrumentation is consistent and decoupled from the SDK.
* The live demo can show logs, traces, and charts with nothing extra to run; the
in-memory buffers are bounded, so memory stays flat over a long session.
* OTEL is now a required dependency (the prior offline/no-new-deps stance is
relaxed for observability, by decision); when `MAL_TRACING=off`, the API's
no-op tracer keeps overhead negligible.
* `setup_logging` configures the **root** logger (the engine had none); it only
manages handlers it tags `_mal`, leaving third-party handlers intact and making
re-configuration idempotent.
* Future work (not in this ADR): optional OTLP export to an external collector,
and mirroring the in-process metrics to an OTEL `MeterProvider`.