Observability with OpenTelemetry
Observability with OpenTelemetry is the Claude Code process exporting traces, metrics and log events to a collector you run. The SDK produces no telemetry of its own; it passes the configuration down and the process does the exporting.
Last updated: 20 Oct, 2026 · Claude Agent SDK
Every example on this page is shown, not run. Exporting needs a collector listening on an OTLP endpoint, which this course does not stand up, so there is no output here and none is invented. The behaviour below comes from the observability page.
Where the telemetry comes from
The instrumentation sits in the Claude Code binary the SDK starts, not in the SDK. That process records a span around each model request and each tool run, counts tokens and cost as metrics, and writes structured log records for prompts and tool results. Configuration reaches it as environment variables, which the child process inherits from your application unless you override them.
- Metrics hold counters for tokens, cost, sessions and tool decisions, and are turned on with
OTEL_METRICS_EXPORTER. - Log events are structured records for each prompt, API request, API error and tool result, turned on with
OTEL_LOGS_EXPORTER. - Traces are spans for each interaction, model request, tool call and hook, and need
OTEL_TRACES_EXPORTERtogether withCLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1.
Turning the three signals on
Nothing is exported until CLAUDE_CODE_ENABLE_TELEMETRY is set and at least one exporter is chosen. The usual setting sends all three signals over OTLP to one collector.
OTEL_ENV = {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
# traces are in beta and need this; metrics and log events do not
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example.com:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer <your-collector-token>",
}Two places will do for these. Setting them in the container or the orchestrator means every call picks them up with no code change, which is what a deployment wants. Passing them per call through env suits one process running agents that need different settings; in Python the dictionary is merged on top of the inherited environment, so the rest of it survives.
options = ClaudeAgentOptions(env=OTEL_ENV)
async for message in query(prompt="List the files here", options=options):
print(message)To see whether it worked, read the collector's own log for arriving spans. The process drops telemetry quietly when the endpoint refuses it, and the agent keeps running, so a silent collector is the only sign. Setting CLAUDE_CODE_OTEL_DIAG_STDERR=1 turns those failures into diagnostics you can read through the SDK's stderr callback.
Getting data out of a short call
Export happens on a timer, and a task that finishes first can end before anything is sent. The documented defaults are a minute for metrics and five seconds for traces and logs, with a short flush on a clean exit; a process that is killed loses whatever is still buffered. Shortening all three intervals narrows both windows.
OTEL_ENV |= {
# a second each, so a task that lasts seconds still reaches the collector
"OTEL_METRIC_EXPORT_INTERVAL": "1000",
"OTEL_LOGS_EXPORT_INTERVAL": "1000",
"OTEL_TRACES_EXPORT_INTERVAL": "1000",
}The spans one run produces
With the beta flag set, each step of the loop becomes a span. These are the names to look for in a backend; their attributes are listed in the monitoring reference and may change while tracing is in beta.
claude_code.interactionwraps one turn, from the prompt arriving to the reply going out.claude_code.llm_requestwraps one call to the Claude API, with the model, the latency and the token counts on it.claude_code.toolwraps one tool call, with children for the wait on a permission decision and for the run itself.claude_code.hookwraps one hook, and needs the detailed tracing beta on top of the one above.
The request, tool and hook spans hang under the interaction span. When the agent hands work to a subagent, the subagent's spans nest under the tool span that started it, so one trace holds the whole chain. Spans carry a session.id attribute, which is how several calls against one session are read as a single timeline.
Joining the agent's trace to your own
If a span of yours is active when you call the SDK, the SDK injects TRACEPARENT and TRACESTATE into the child process, and the interaction span becomes a child of your span rather than a trace on its own. Setting TRACEPARENT yourself in env switches that off, which is how you pin a parent of your choosing (linking traces to your application).
Naming the service and tagging the tenant
The process reports itself as claude-code by default, which is no help once two agents share a collector. A service name and a few resource attributes land on every span, metric and event.
from urllib.parse import quote
# commas, spaces and equals signs are separators here, so encode before interpolating
tags = f"enduser.id={quote(user_id)},tenant.id={quote(tenant_id)}"
options = ClaudeAgentOptions(env=OTEL_ENV | {
"OTEL_SERVICE_NAME": "shop-help-desk",
"OTEL_RESOURCE_ATTRIBUTES": f"service.version=1.4.0,{tags}",
})The identity attributes the process attaches by itself describe the credential it called Anthropic with, which in a service serving many people is your credential and not theirs. Putting the end user on each call is what turns the tool and permission events into a per-person audit trail.
What the export holds back
Telemetry is structural by default: durations, model names and tool names, and token counts where the API reported them. What the agent reads and writes stays out unless you ask for it, one variable at a time.
| Variable | What it adds to the export |
|---|---|
OTEL_LOG_USER_PROMPTS=1 | The prompt text, on the user-prompt events and on the interaction span |
OTEL_LOG_TOOL_DETAILS=1 | Tool arguments: file paths, shell commands, search patterns, and the real agent, skill, plugin and server names on the cost metrics |
OTEL_LOG_TOOL_CONTENT=1 | What the tools returned, as a span event, truncated by default and needing tracing turned on |
OTEL_LOG_RAW_API_BODIES | The whole request and response JSON, which is the entire conversation history |
When exporting pays for itself
- A long-running agent where nobody is watching the terminal, and a stalled session has to show up somewhere.
- A deployment serving many people from one credential, where tool calls need to be attributable to the person who asked.
- A cost question that the per-call figures cannot answer, because it spans thousands of sessions.
console as an exporter here. The console exporter writes telemetry to standard output, and standard output is the channel the SDK reads messages on, so the stream is corrupted. Point the endpoint at a local collector when you want to read the data by hand.Related
- Previous: Cost and usage
- Next: Todo tracking
- See also: Hosting the SDK
- Reference: Observability with OpenTelemetry
- Run an OpenTelemetry Collector locally, point the endpoint at it, and read its log after one call.
- Drop
CLAUDE_CODE_ENHANCED_TELEMETRY_BETAand see which signals still arrive. - Set
OTEL_SERVICE_NAMEto two different values in one process and filter the backend by each.
This is what real progress feels like.