| # 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`. |
|
|