OpenTelemetry GenAI — Tracing Tool Calls End-to-End
An agent calls five tools, three MCP servers, and two sub-agents, and you need one trace across all of it. The OpenTelemetry GenAI semantic conventions (stable attributes in v1.37 and up) are the 2026 standard, natively supported by Datadog, Langfuse, Arize Phoenix, OpenLLMetry, and AgentOps. This lesson names the required attributes, walks the span hierarchy (agent → LLM → tool), and ships a stdlib span emitter you can plug into any OTel exporter.
Learning Objectives
- Name the required OTel GenAI attributes for an LLM span and a tool-execution span.
- Build a trace hierarchy that covers agent loop, LLM call, tool call, and MCP client dispatch.
- Decide what content to capture (opt-in) vs redact (defaults).
- Emit spans to a local collector (Jaeger, Langfuse) without rewriting tool code.
The Problem
A debug from February 2026: a user reports "my agent sometimes takes 30 seconds to respond; other times 3 seconds." There are no traces. Logs show the LLM call, but not the tool dispatch, the MCP server round-trip, or the sub-agent. So you guess, and eventually you find that one MCP server occasionally hangs on a cold start.
Without end-to-end tracing you cannot find that. OTel GenAI is how you get it.
The conventions settled in 2025-2026 under the OpenTelemetry semantic-conventions group. They define stable attribute names, so Datadog, Langfuse, Phoenix, OpenLLMetry, and AgentOps parse the same spans. You instrument once and ship to whichever backend you pick.
The Concept
Span hierarchy
agent.invoke_agent (top, INTERNAL span)
├── llm.chat (CLIENT span)
├── tool.execute (INTERNAL)
│ └── mcp.call (CLIENT span)
├── llm.chat (CLIENT span)
└── subagent.invoke (INTERNAL)
The whole tree nests under one trace id, and span ids link each child to its parent.
Required attributes
Per the 2025-2026 semconv, an LLM span carries:
gen_ai.operation.name:"chat","text_completion","embeddings","execute_tool", or"invoke_agent".gen_ai.provider.name:"openai","anthropic","google","azure_openai".gen_ai.request.model: the requested model string (e.g."gpt-4o-2024-08-06").gen_ai.response.model: the model actually served.gen_ai.usage.input_tokensandgen_ai.usage.output_tokens.gen_ai.response.id: the provider's response id, for correlation.
Tool spans add:
gen_ai.tool.name: the tool identifier.gen_ai.tool.call.id: the specific call id.gen_ai.tool.description: the tool description (optional).
Agent spans add gen_ai.agent.name, gen_ai.agent.id, and gen_ai.agent.description.
Span kinds
SpanKind.CLIENT is for calls that cross a process boundary, like the LLM provider or an MCP server. SpanKind.INTERNAL is for the agent's own loop steps and tool execution.
Opt-in content capture
By default, spans carry metrics and timing, not prompts or completions. Large payloads and PII stay off. To include content you set OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental plus the specific content-capture env vars. Review carefully before enabling that in prod.
Events on spans
Token-level detail goes in as span events:
gen_ai.content.promptfor input messages.gen_ai.content.completionfor output messages.gen_ai.content.tool_callfor the tool call as recorded.
Events are time-ordered within a span, which gives you a detailed replay.
Exporters
OTel spans export to:
- Jaeger / Tempo. OSS, on-prem.
- Langfuse. Built for LLM observability, and visualizes token usage.
- Arize Phoenix. Evals and tracing together.
- Datadog. Commercial, and natively parses
gen_ai.*attributes. - Honeycomb. Column-oriented and friendly to ad-hoc queries.
These backends speak OTLP, the wire format, so your instrumentation code does not need to know which one is on the other end.
Propagation across MCP
When an MCP client calls a server, inject the W3C traceparent header into the request. Streamable HTTP supports standard headers. Stdio has no HTTP headers, and the spec's 2026 roadmap discusses adding a _meta.traceparent field on JSON-RPC calls.
Until that ships, put the traceparent in the _meta of each request by hand and have the server log the trace id.
Metrics
Alongside spans, the GenAI semconv defines three histograms:
gen_ai.client.token.usagegen_ai.client.operation.durationgen_ai.tool.execution.duration
Use them for dashboards that don't need per-call detail.
AgentOps layer
AgentOps (founded 2024) specializes in GenAI observability. It wraps popular frameworks (LangGraph, Pydantic AI, CrewAI) to emit OTel spans automatically. That helps if your stack uses a supported framework. Otherwise, instrument by hand.
Use It
code/main.py emits OTel-shaped spans to stdout (OTLP-JSON-like) for an agent that makes two LLM calls and three tool calls, each followed by one MCP round-trip. That adds up to nine spans in one trace. There is no real exporter here, and the printed lines are a summary of each span (name, kind, shortened ids, duration, gen_ai.* attributes, event count), not full OTLP. The point is the span shape and the attribute set, so read the output directly. The hierarchy diagram's subagent.invoke span is not emitted.
What to look at:
- One trace id is shared across every span.
- Parent-child links are encoded in
parentSpanId. Themcp.callspans hang offtool.execute, and the LLM and tool spans hang off the root. - The required
gen_ai.*attributes are populated on the LLM and tool spans. - Content capture is off by default. Run
OTEL_CAPTURE_CONTENT=1 python code/main.pyto turn it on and add the prompt and completion events. That variable belongs to the toy. A real setup uses the opt-in variables from the Opt-in content capture section.
Ship It
This lesson produces outputs/skill-otel-genai-instrumentation.md. Give it an agent codebase and it returns an instrumentation plan: where to add spans, which attributes to populate, and which exporters to target.
I'd run main.py once with content capture off and once with OTEL_CAPTURE_CONTENT=1, and compare the two outputs. Nine spans print either way. With the variable set, the LLM spans also gain prompt and completion events, which shows how little code separates "metrics and timing" from "your users' text in the trace store."
The part worth reading closely is fake_mcp_call. It builds the traceparent from the MCP span's own trace id and span id and stores it as an attribute in the source, so you can see exactly what a server would need to receive. The printed output filters to gen_ai.* attributes, so the traceparent never shows up there. The toy never sends it either. It sits on the span as an attribute, not in a _meta field, so wiring it into a real request is the step you'd add yourself. That is also what Exercise 4 asks for.
I'd also keep the toy in proportion. Every span is hand-built, the LLM and the tools are fakes, and nothing computes the histograms from the Metrics section or sets gen_ai.tool.description or the agent description. In a real agent I'd reach for an OTel SDK and an OTLP exporter rather than maintain a span class, and keep this file as the reference for what the spans should contain.
Exercises
-
Run
code/main.py. Count the spans and identify which is CLIENT vs INTERNAL. -
Turn on content capture (env var) and confirm
gen_ai.content.promptandgen_ai.content.completionevents appear. Note the implications for PII. -
Add the tool-execution metric
gen_ai.tool.execution.durationand emit it as a histogram sample per call. -
Propagate a traceparent from a parent agent span into an MCP request's
_meta.traceparentfield. Verify the MCP server would see the same trace id. -
Read the OTel GenAI semconv spec. Identify one attribute listed in the semconv that this lesson's code does NOT emit. Add it.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| OTel | "OpenTelemetry" | Open standard for traces, metrics, logs |
| GenAI semconv | "GenAI semantic conventions" | Stable attribute names for LLM / tool / agent spans |
gen_ai.* | "The attribute namespace" | All GenAI attributes share this prefix |
| Span | "Timed operation" | A unit of work with a start, end, and attributes |
| Trace | "Cross-span ancestry" | Tree of spans sharing a trace id |
| SpanKind | "CLIENT / SERVER / INTERNAL" | Hints about span direction |
| OTLP | "OpenTelemetry Line Protocol" | Wire format for exporters |
| Opt-in content | "Prompt / completion capture" | Off by default; env var to enable |
| traceparent | "W3C header" | Propagates trace context across services |
| Exporter | "Backend-specific shipper" | Component that sends spans to Jaeger / Datadog / etc. |
Further Reading
- OpenTelemetry: GenAI semconv: canonical conventions for GenAI spans, metrics, and events
- OpenTelemetry: GenAI spans: LLM and tool-execution span attribute list
- OpenTelemetry: GenAI agent spans: agent-level
invoke_agentspan - open-telemetry/semantic-conventions: GenAI spans: GitHub-hosted source of truth
- Datadog: LLM OTel semantic convention: production integration walkthrough
Code
- main.py (python, 178 lines)