Observability¶
Two views of a harness run. The narrated trace is on by default and
needs nothing installed. The --otel span tree is the opt-in second
view — and, across five runtimes, the vendor-neutral axis that makes them
actually comparable.
1. The narrated trace (default)¶
Every *_harness/run.py and compare.py prints the same structured block,
built from shared/trace.py:
$ uv run python -m strands_harness.run
strands · orchestrator-workers
────────────────────────────────────────────────────────────────
fan-out owner : Agents-as-Tools — a plain @tool runs a fresh Agent per call, one call per subtask
subtasks (runtime) : 3 — pricing, onboarding, support quality
isolation : a new Agent() per research() call keeps each subagent's context isolated
synthesis : orchestrator merges the 3 returned reports
...
strands · human-in-the-loop
────────────────────────────────────────────────────────────────
gate owner : HumanInTheLoop(allowed_tools=[...]) — an inverted, allow-list intervention
gate fired : yes → run PAUSED before the tool ran
decision : APPROVED → tool ran
resume : decided inline by the `ask` callback wired in up front — no separate resume call
paused state : in-memory
The fields are identical across harnesses on purpose — fan-out owner,
subtasks (runtime), gate owner, resume, paused state — so two
harnesses diff cleanly and the vocabulary matches the vanilla repo's
narrated traces.
compare.py stacks all installed harnesses under aligned headers.
2. The OpenTelemetry span tree (--otel)¶
--otel initialises Traceloop's OpenLLMetry
SDK and wires its spans, on the
OpenTelemetry GenAI semantic
conventions, to a
console exporter (shared/obs.py) — no backend, no API key. Point it at a
real OTLP backend (Arize Phoenix,
Langfuse, Jaeger, Grafana Tempo) with
OTEL_EXPORTER_OTLP_ENDPOINT + AIH_OTEL_OTLP=1.
Coverage varies by framework — and that's a finding¶
| Harness | What --otel (OpenLLMetry) captures |
Native OTel of its own |
|---|---|---|
| deepagents | Full: langgraph root, one span per node, create_agent / chat spans with gen_ai.* attributes — it's LangChain under the hood, which OpenLLMetry instruments directly |
(via LangChain instrumentation) |
| OpenAI Agents SDK | The underlying OpenAI client calls; the SDK's own agent/handoff spans need its built-in tracing | Yes — built-in tracing with an OTel processor |
| Microsoft Agent Framework | Little from OpenLLMetry directly | Yes — emits OTel spans natively; enable via its observability setup |
| Strands Agents SDK | Little from OpenLLMetry directly | Yes — OTel support built in |
| Dapr Agents | Little from OpenLLMetry directly | Yes — Dapr emits OTel across the sidecar |
With the offline fake models there are no real model round-trips, so the
tree is structural (agent/node/tool spans and their nesting), not
token-level. Run a harness against a real provider (LLM_PROVIDER=anthropic
+ key) to get gen_ai.usage.* and latency on the chat spans.
The lesson the span tree teaches that the narrated trace can't: a hand-built LangGraph pattern and deepagents share one instrumentation story (instrument LangChain, done); each of the other four frameworks brings its own OTel integration you'd have to learn and wire. "Let a framework supply the pattern" also means "adopt that framework's observability", and those are five different adoptions.
Which one do I want?¶
- Understanding a framework's mechanism, or diffing two → the narrated
trace /
compare.py. - What production sees — span nesting, latency, token cost, a tree you
can ship to a backend →
--otel. - A vendor-neutral axis to compare frameworks on →
--otel, accepting that each framework's native OTel goes deeper than OpenLLMetry's cross-cutting instrumentation does.