Observability Roadmap¶
Status of the pluggable trace-exporter work (Langfuse + OpenTelemetry). User-facing usage lives in the Observability section; this file tracks what's built and what's left.
Legend: ✅ done · 🚧 in progress · ⬜ not started
Design summary¶
Agent runs emit a typed event stream (ToolStarted, TurnCompleted{usage}, …). A side-channel observer on that stream (base._tap) translates it into backend-neutral TraceExporter calls: run → trace, LLM turn → generation (with token cost), tool call → span. Auto-on from the environment (LANGFUSE_* / OTEL_EXPORTER_OTLP_ENDPOINT); zero overhead when unconfigured; a bad or unreachable exporter never breaks a run. Nesting is carried by an ambient TraceContext published in a contextvar and inherited by runs that start inside another run.
Code: neurosurfer/observability/ (context.py, exporters/), neurosurfer/config/observability.py.
Phase 0 — Foundations ✅¶
-
TraceContext(trace/span/parent/session ids) reusingnew_run_id -
ObservabilityConfigenv auto-detection (detect_exporters_from_env), off by default -
observabilityextra inpyproject.toml(lazy imports)
Phase 1 — Exporter interface ✅¶
-
TraceExporterprotocol (on_run_start/on_turn/on_tool_*/on_event/on_error/on_run_finish) -
NullExporter+MemoryExporter - Lazy, env-aware, fail-soft registry (unknown / missing SDK → warn + skip)
Phase 2 — Event-stream observer ✅¶
-
TraceStreamObservertranslates the event stream → exporter lifecycle - Wired into
base._tap()besideAgentTrace; independent ofverbose - Exporter exceptions swallowed — never break a run
Phase 3 — Langfuse adapter ✅¶
- run → trace, turn → generation (model + token usage → cost), tool → span
- Env config (
LANGFUSE_*); verified live in the Langfuse UI
Phase 4 — OpenTelemetry adapter ✅¶
- GenAI-semconv spans over OTLP; own
TracerProvider(doesn't touch global) - Root → turn/tool child spans
Phase 6 — Docs, tests, polish ✅¶
-
docs/guides/observability.md+ mkdocs nav - Tests: config detection, event→lifecycle mapping, live agent-run wiring, fail-soft
- CHANGELOG +
.env.example
Sub-agent nesting + sessions ✅¶
- Ambient
TraceContextin a contextvar; nested runs inherit trace/session, nest under parent span - Propagates across
awaitandasyncio.gather(sequential + parallel sub-agents) - Langfuse + OTel exporters key state by
span_id, parent under the enclosing run -
session_idon agents; CLI sets one per conversation (reset on/clear) - Tests + live Langfuse nested-trace verification
-
.envloader hardening (strip trailing inline comments; keep quoted#)
Phase 5 — Graph / workflow-executor nesting ✅ (code complete; live check pending)¶
Make a multi-node workflow render as one nested trace. Agent/sub-agent nesting was already done; this extended it to the graph executor path in two levels — both now built. Only a live multi-node Langfuse verification remains (needs credentials).
Level 1 — Workflow = one trace, node agents nested under it ✅ (~1 hr, low risk)¶
- Mint a root
TraceContextfor the graph run;push_trace_contextaround it in the workflow runner (traced_runin observability/run.py, wrappingexecutor.runin graph/workflow/runner.py) - Node agents already flow through
base._tap()→ they nest automatically - Test (tests/test_observability_workflow.py: root span + node nesting + no-exporter no-op)
- Thread propagation: parallel nodes (
parallelism>1) and timeout nodes (policy.timeout_s) hop toThreadPoolExecutorworkers, andrun_coro_blockingmay spawn a thread — all now run inside acontextvars.copy_context()snapshot, so the ambientTraceContextcrosses the thread boundary and those node agents nest too (executor.py, node_runner.py; tests cover both) - One live Langfuse check
- Result:
workflow:<name>(root) → each node's agent run → its tool spans, one trace. - Note: nodes nest whether they run serially, in parallel, or under a timeout. The per-node span layer (Level 2) is now built on top of this.
Level 2 — Full graph-run → node span → agent → tool hierarchy ✅ (built)¶
- Per-node span via a lightweight node-level context — each node's execution in
GraphExecutor._execute_oneis wrapped intraced_run("node:<id>", flush=False)(executor.py), publishing an ambientTraceContextthe node's agent inherits.traced_rungained aRunSpanhandle so a node that returns (not raises) an error marks its span errored (observability/run.py) - Nodes that bypass
agent.run(function / tool nodes) are now visible — they get their own node span even with no agent underneath - Correct nesting for parallel branches — the
copy_context()snapshot from Level 1 means each concurrent node grabs its own node span as parent; verified with a 2-nodeparallelism=2graph - Deeper nesting needs no exporter change: Langfuse + OTel both resolve the parent by
parent_span_idand register every span, soworkflow → node → agent → toolrenders at arbitrary depth - Tests: workflow→node→agent hierarchy, function-node visibility, error marking, parallel + timeout thread nesting (tests/test_observability_workflow.py)
- One live multi-node Langfuse check (needs credentials — run locally)
Out of scope of the exporter work (separate subsystems, left as-is): - The structured JSON Tracer and its dead NodeExecutionResult.traces / GraphExecutionResult.traces fields (graph/engine/schema.py) — a different tracing path from the pluggable exporters; not needed for backend nesting. - Per-node token usage aggregation on GraphExecutionResult (react nodes drop it in node_runner.py). Usage is still captured in the trace per turn via each agent's on_turn; only the result-object rollup is missing.
Recommendation: ship Level 1 first (visible win, near-zero risk), then decide if Level 2's per-node polish is worth the extra half-day.