
Claude API OpenTelemetry Tracing: Setup and 6 Gotchas
Claude API OpenTelemetry tracing comes in two halves: register a tracer provider and the Claude SDK emits a span per request, or add a Console destinationand Anthropic's servers send you spans for every model run and server tool. It went into public beta on October 14, 2026. Turn on both and they join into one trace. I wired up the SDK side the day after launch, and the spans are good. Two things will bite you first: the token attributes don't mean what the raw API response means, and your sampler now decides what Anthropic exports too.
What Does Claude's OpenTelemetry Support Actually Trace?
Until this week, tracing a Claude call meant a wrapper library guessing at the request from the outside. Now there are two first-party sources. The SDK traces what your process sent. The API traces what happened on Anthropic's side, which is the part you could never see before: how many model runs a request took, how long the web search tool ran, which Managed Agents turn stalled.
| What you get | SDK tracing | API tracing |
|---|---|---|
| Messages API and Sessions API requests | Yes | Yes |
| Token usage | Yes | Yes |
| Each model run inside a request | No | Yes |
| Server tools (web search, code execution) and MCP connector tools | No | Yes |
| Managed Agents turns and outcome evaluations | No | Yes |
| Client tools run by the SDK tool runner | Yes | No |
| Setup | Code, no special role | Console, org admin |
Both use the OpenTelemetry GenAI semantic conventions, so Langfuse, Datadog, Grafana, Honeycomb and the rest recognize the spans without custom mapping. Anthropic's OpenTelemetry overview lists twelve partner platforms, but any OTLP endpoint works. If you only do one, do the SDK side: it's free, needs no admin role, and works on Bedrock and Google Cloud too. The API side is only for the Claude Platform and Claude Platform on AWS.
How Do I Turn On Claude SDK Tracing?
Upgrade the SDK and register a tracer provider before your first call. That's it. Here's the Python version with an OTLP exporter:
pip install --upgrade anthropic opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
from anthropic import Anthropic
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider(resource=Resource.create({"service.name": "lead-router"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) # reads OTEL_EXPORTER_OTLP_*
trace.set_tracer_provider(provider) # before the first Claude call
client = Anthropic()
tracer = trace.get_tracer("lead-router")
with tracer.start_as_current_span("score_lead"):
client.messages.create(model="claude-haiku-5-5", max_tokens=512, messages=[...])I ran this with anthropic 1.14.0 and opentelemetry-sdk 1.45.1, swapping the OTLP exporter for an in-memory one so I could dump every attribute. The SDK span for a cached request looked like this (request ID trimmed):
anthropic.messages.create SpanKind.CLIENT status OK
scope com.anthropic.sdk.python 1.14.0
gen_ai.operation.name chat
gen_ai.provider.name anthropic
gen_ai.request.model claude-haiku-5-5
gen_ai.conversation.id conv_42
gen_ai.usage.input_tokens 10252
gen_ai.usage.cache_read.input_tokens 8192
gen_ai.usage.cache_write.input_tokens 2048
gen_ai.usage.output_tokens 7
anthropic.request.id req_...
anthropic.message.stop_reason end_turnThree settings worth knowing on day one. ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content adds prompts and responses (default is metadata only). conversation_id stamps gen_ai.conversation.idso you can group a chat's calls; it's exported even in metadata mode, so never put an email address in it. And ANTHROPIC_OPEN_TELEMETRY=false turns spans off if another library already traces the SDK.
Set a service name. Without one, my spans came out as unknown_service:python, which is useless the minute you have more than one worker.
How Do I Send Claude API Spans to Langfuse or My Own Collector?
You need the admin role in the Console org. Go to Settings, OpenTelemetry, Add destination. Pick a listed provider and the Console asks for its keys, or pick Custom and give it a base URL plus headers. Don't append /v1/traces; the API adds it. You can script the same thing with the Admin API:
curl --fail-with-body -sS https://api.anthropic.com/v1/organizations/telemetry_destinations \
-H "x-api-key: $ANTHROPIC_ADMIN_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: telemetry-destinations-2026-08-11" \
-H "content-type: application/json" \
-d '{
"endpoint_url": "https://cloud.langfuse.com/api/public/otel",
"headers": {"authorization": "Basic <base64 of pk:sk>"},
"traces": {"content_mode": "metadata_only"},
"workspaces": {"type": "all"}
}'Then hit Test in the destination's menu, look for an anthropic.test span, send one real request, and wait for the Delivery column to say Healthy (it lags up to a minute). API spans arrive under service.name = claude-platform.
The endpoint rules are strict, and most self-hosted setups break at least one. It has to be OTLP/HTTP with protobuf, HTTPS on port 443, a hostname rather than an IP, resolving to a public address. TLS handshake and response headers each get 5 seconds, 10 seconds total per delivery. And the API disables the destination outright if the endpoint returns any 3xx, resolves to a private IP, or fails every delivery for 24 hours.
So a Langfuse on localhost:3000or a collector on a Tailscale IP will happily take your SDK spans and never see a single API span. Most of my boxes sit behind Cloudflare Tunnels, which give you a public 443 hostname for free. If you also put Cloudflare Access in front, the destination's custom headers should carry the service token pair, the same pattern I use for locking down n8n webhooks with service tokens. I haven't pushed API spans through Access yet, so test it with the Test button before you trust it. And watch redirects: a host that 302s to www. or forces a trailing slash will get the destination switched off.
What Are the Gotchas I Hit Testing It?
1. gen_ai.usage.input_tokens already includes cached tokens
I pointed the SDK at a local stub that returned a canned response: input_tokens: 12, cache_creation_input_tokens: 2048, cache_read_input_tokens: 8192. The span said gen_ai.usage.input_tokens = 10252. That's 12 + 2,048 + 8,192.
The raw API's input_tokens means uncached input. The span's means all input. If you port a cost formula from your logs (input × base price + reads × read price) onto span data, you bill every cached token twice, once at the full rate. On a cache-heavy agent that's the difference between a sane dashboard and one claiming you spend 10x reality. Subtract both cache attributes first. If caching is new to you, my prompt caching cost guide has the price math.
2. Every request shows up twice
With both halves on, a request has an SDK span and an API span, and both are named anthropic.messages.create. One is CLIENT, its child is SERVER. A naive "sum tokens where name = messages.create" doubles your spend. Sum only the server spans (filter on service.name = claude-platform), and inside those pick either the request spans or their anthropic.model_request children, never both. Deliveries are at least once too, so dedupe on trace ID plus span ID.
3. Your sampler now samples Anthropic's spans
The SDK sends a W3C traceparent header with each request. Inside a sampled span, mine ended in -03. Under a 0% parent-based sampler it ended in -02, sampled bit off, and my exporter got zero spans. Per Anthropic's docs, the API also skips every span for a request whose sampled flag is off.
A 10% head sampler in your app quietly becomes a 10% sampler on the server side. That's fine for latency charts and wrong for cost tracking. For real spend, use the Usage and Cost API, which counts requests with no spans at all.
4. A stream you abandon never ends its span
I broke out of a streamed response after three events without calling close(). Zero finished spans; the span died with the process. Adding stream.close() exported it immediately. Also, messages.create(stream=True) read to the end recorded output tokens but no response text in content mode. The messages.stream() helper recorded the full reply. If you need content, use the helper.
5. Error spans carry the type, not the message
I also hit the real API from an org with no credit balance (a state I wrote about in my Claude Max API credits post). The span came back ERROR with error.type = invalid_request_error and http.response.status_code = 400, but no message text. "Credit balance too low" and "bad parameter" look identical. Log the exception message on your own span. That error span also carried anthropic.organization.id and anthropic.workspace.id in metadata mode, which is handy for routing and worth knowing if you share traces with a vendor.
6. Node auto-instrumentation can corrupt the trace
If you use @opentelemetry/auto-instrumentations-node, its undici instrumentation adds a second traceparentheader, which makes the trace context invalid. Anthropic's docs flag the upstream bug. Fix: new Anthropic({ openTelemetry: { propagation: false } }).
Should I Export Metadata Only or Full Content?
Metadata only is the default: IDs, timing, status, token counts, tool and agent names. Full content adds gen_ai.system_instructions, gen_ai.input.messages and gen_ai.output.messages, capped at 0.75 MB per span. Past the cap, the API drops the system prompt and oldest messages first and sets anthropic.content.truncated. A full 1M-token context needs about 4 MB if your backend accepts it.
My default: metadata in production, full content on a separate destination scoped to a staging workspace. Once content leaves Anthropic it lives under your vendor's retention, and a trace store full of customer emails is a breach waiting for a reason. Orgs with HIPAA, FERPA or K-12 terms get metadata only from the API regardless, though SDK spans aren't restricted, so check your client config too.
Is It Worth Turning On Today?
Yes, with the beta caveat taken seriously. Anthropic says the span schema and SDK options can change in breaking ways during the beta, so pin the SDK version and don't build billing on attribute names yet. I'd turn SDK tracing on everywhere now and add the Console destination wherever you run server tools or Managed Agents, because that's where the server-side view pays for itself.
If your agents run in n8n rather than your own code, none of this reaches you directly; n8n calls the API with its own HTTP client. My n8n AI agent observability post covers that side. The Console destination still catches those requests, since it traces any client in the workspace.
How I tested this: on October 15, 2026 I ran anthropic 1.14.0 (Python) with opentelemetry-sdk1.45.1 and an in-memory span exporter, against a local stub returning fixed usage numbers and a stub SSE stream, plus one live call to the Claude API that failed on credit balance. I did not add a Console destination for this post, so the API-side details (endpoint rules, timeouts, disable conditions, token counting) come from Anthropic's OpenTelemetry docs read the same day.
Claude API OpenTelemetry FAQ
Does the Claude API support OpenTelemetry?
Yes, in public beta since October 14, 2026. The Claude SDKs emit client spans when you register a tracer provider, and the Claude API itself can send server spans to any OTLP/HTTP endpoint you add as a destination in the Claude Console.
Do I need to change my code to get Claude SDK spans?
Only to register an OpenTelemetry tracer provider before the first call, with the latest SDK installed. Your messages.create calls stay the same. Content capture, conversation IDs and propagation are optional client settings or ANTHROPIC_OPEN_TELEMETRY environment variables.
Why does gen_ai.usage.input_tokens not match input_tokens from the API?
The span's gen_ai.usage.input_tokens already adds cache reads and cache writes. A response with input_tokens 12, cache_creation_input_tokens 2,048 and cache_read_input_tokens 8,192 shows 10,252 on the span. Price the cache attributes separately and subtract them, or you overbill cached tokens at the full input rate.
Can I send Claude API traces to a self-hosted collector?
Yes, if it accepts OTLP/HTTP protobuf at an HTTPS hostname on port 443 that resolves to a public IP and answers without redirects. A collector on localhost or a private Tailscale address gets SDK spans from your app but never the API spans.
Can I use Claude OpenTelemetry spans for billing?
Not as the source of truth. Delivery is best effort and at least once, and an unsampled traceparent drops a request's API spans entirely. Use spans for debugging and cost trends, and the Usage and Cost API for invoices and per-key usage.
Want to See What Your Agents Are Doing?
I build Claude agents for real businesses and wire them for tracing from day one: spans, token dashboards that don't double count, and alerts when a tool loop runs away. Tell me what you're running and I'll tell you where the blind spots are.
Related Posts
AI Agents
ant apply: Manage Claude Agents as Code
ant apply turns Claude agents, skills, environments, memory stores and scheduled deployments into files in your repo, with a Terraform-style plan-and-apply loop. How claude-lock.json detects drift with two hashes, why renaming a file silently duplicates an agent, the adoption gap for Console-made resources, and the five rules for running it in CI.
AI Agents
Claude Agent Memory Stores vs the Memory Tool
A memory store is a versioned folder Anthropic mounts into your agent's sandbox at /mnt/memory. How it differs from the client-side memory tool, the read_write default that enables persistent prompt injection, the 10,000-memory cap that fails silently, and the 15-second sync on self-hosted sandboxes.
AI Agents
Claude Outcomes: Rubric Grading for AI Agents
Outcomes hands your agent's output to a second agent in a separate context window, scored against a rubric you write. The API, the iteration cost multiplier, and the checklist item my own pipeline skipped for 27 days.