Claude API OpenTelemetry: tracing the SDK and the API without counting tokens twice
A span's input token count already includes cache reads and writes, so the query that feels natural if you know the Messages API counts the cached prefix twice. Setup for the SDK half, tested against a local mock; the Console destination from the docs; and the subtraction that gets you back to uncached input.
Contents
Anthropic shipped OpenTelemetry export for the Claude API on October 14, in beta. The release note is one paragraph: the Claude SDK emits spans in seven languages, and the API emits its own once you add a destination in the Console. It reads like one feature. It is two, with different owners and different failure modes, and the place where they meet is one HTTP header that your application’s sampler controls.
That matters most for the use people will reach for first, a token dashboard. The two halves report the same request twice under the same span name, and the token attribute they share does not mean what input_tokens means in a Messages API response.
What I ran: the Python SDK half, against a local mock of /v1/messages that returns a fixed response with cache usage, so no token was spent. The token numbers below come straight from that fixed response and repeat on every run; trace and span ids are random each time. What I did not run: the API half. It needs an organization admin and a public HTTPS endpoint, so those steps come from the docs and I mark them as such.
Which half of Claude API OpenTelemetry do you need, SDK tracing or API tracing?
Start with SDK tracing, because it needs nothing but your code; add API tracing when you need to see inside a request. The SDK traces what your process does: each request it sends, each client tool the tool runner runs, file uploads and other calls. The API traces what happens on Anthropic’s side: each model run inside a request, server tools like web search, MCP connector calls, and the turns of a Claude Managed Agents session.
The requirements differ just as much. SDK tracing needs no role and works on any platform the SDK supports, Bedrock, Google Cloud and Foundry included. API tracing needs the admin role in a Console organization and works only on Claude Platform and Claude Platform on AWS. If you run Managed Agents, the API half is the only one that sees what the agent did between your calls; the SDK only sees you sending events, reading the stream and running your custom tools.
How do you enable OpenTelemetry in the Claude Python SDK?
Install the SDK and an OpenTelemetry SDK, register a tracer provider before the first call, and leave your Claude calls unchanged. The SDK picks up the globally registered provider on its own. I pinned the versions I tested:
pip install "anthropic==1.15.0" "opentelemetry-sdk==1.45.1"The SDK guide’s minimal example prints spans to the console:
from anthropic import Anthropic
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
client = Anthropic()
client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
)To run it offline I pointed the client at the mock with Anthropic(api_key="sk-test", base_url="http://127.0.0.1:8765"). The mock is a few lines of http.server that answers every POST with one canned message: 120 uncached input tokens, 2,000 tokens written to the 1-hour cache, 5,000 read from cache and 40 output tokens. One call produced one span. Trimmed to the lines that matter:
name anthropic.messages.create
kind SpanKind.CLIENT
gen_ai.request.model claude-opus-5-5
anthropic.request.id req_mock01
anthropic.message.stop_reason end_turn
gen_ai.usage.input_tokens 7120
gen_ai.usage.cache_write.input_tokens 2000
gen_ai.usage.cache_read.input_tokens 5000
anthropic.usage.cache_creation.ephemeral_1h_input_tokens 2000
gen_ai.usage.output_tokens 40For a real backend you swap the console exporter for an OTLP exporter. The rest is configuration, either in code through open_telemetry= on the client or through environment variables: ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content records prompts and responses, ANTHROPIC_OPEN_TELEMETRY=false turns the SDK’s spans off. One catch from the docs: passing open_telemetry= in code overrides all of those variables as a whole, and any nested key you leave out falls back to its default even if its variable is set.
Pin the SDK version in your project too. The docs say plainly that during the beta a new SDK release can rename a span, an attribute, an option or an environment variable, which would break a dashboard built on the old names without any error.
How do you add a Claude API OpenTelemetry destination?
Per the API tracing guide, not tested here: an organization admin adds a destination in the Console’s OpenTelemetry settings, or creates one through the Admin API with the beta header telemetry-destinations-2026-08-11, and spans start arriving within about a minute. A destination is an OTLP base URL, the headers that authenticate with it, a content mode and the workspaces it covers. The URL is a base: the API appends /v1/traces itself, so https://otel.example.com/otlp receives deliveries at https://otel.example.com/otlp/v1/traces. A backend with a different traces path goes in the separate traces endpoint field, on the same host.
The endpoint rules decide whether a self-hosted collector works at all. It must speak OTLP/HTTP with protobuf on HTTPS port 443, under a hostname rather than an IP address; a stock Collector listens on 4318, so it needs a reverse proxy or a reconfigured receiver. The API disables a destination when the endpoint answers with a 3xx redirect, when its hostname resolves to an address that is not public, or when every delivery fails for 24 hours. A collector reachable only inside a VPN or a tailnet is therefore not an option; it has to be on the public internet, with authentication in front of it.
After saving, the Console’s Test action sends an anthropic.test span, and API spans carry service.name = claude-platform, which is the filter for finding them. An organization gets ten destinations, disabled ones included.
How do Claude API and SDK spans join into one OpenTelemetry trace?
Through the W3C traceparent header. The SDK writes it with the propagator your application registered, the API reads it, and the API’s span becomes a child of the SDK’s span. In Python the default propagator is W3C, so it just works; the docs note that Go, and Java or PHP when configured in code, register none by default and need one added. With no header, the API span for a request starts its own trace as a root.
The mock logged the header the SDK sent. Its parent-id field is the CLIENT span’s own id, 023c7f20402aa439 in hex in that run, so the server span will hang directly under the client span. The trace flags were 03: the sampled bit on, plus the random-trace-id bit from Trace Context Level 2.
Then I swapped the sampler for ParentBased(TraceIdRatioBased(0.0)), which drops every new root trace; the call had no parent, so it was dropped. No span was exported, as expected, but the SDK still sent the header, now with flags 02: same random bit, sampled bit off. The docs say what the API does with that:
If the sampled flag in the header is off, the API doesn't emit the request's span or the spans under it.So your application’s sampler decides whether Anthropic’s side produces any spans for a request at all. A request that leaves your process under an unsampled context gets no API span, and none of its server tools, model runs or Managed Agents turns are traced either. With a uniform 1% head sampler, expect roughly 99% of requests to leave nothing on the API side. Sampling is usually a local cost decision; here it reaches into another system. If you want every API span, the request has to be sampled in your process, or reach the API with no traceparent header and start its own trace.
The opposite switch has a side effect of its own. With ANTHROPIC_OPEN_TELEMETRY=false the SDK emitted no span and, in my run, sent no traceparent either. Unless some other instrumentation adds the header, API spans from such a client arrive as separate root traces you cannot attach to the code that caused them. If the only goal is to stop duplicate headers, because your HTTP client instrumentation already sends traceparent, the SDK guide’s switch is Anthropic(open_telemetry={"propagation": False}), which stops the headers and keeps the SDK spans.
How do you count Claude API tokens once across OpenTelemetry spans?
Pick one source per request and sum only its request spans: when a request has both an SDK span and an API span, the docs say to sum the API spans. Both are named anthropic.messages.create; the SDK one is kind CLIENT, the API one is SERVER. The SDK guide’s troubleshooting table lists the symptom as “Each request appears twice”.
Inside the API spans, usage repeats a level down. It sits on the anthropic.messages.create server span and again on each anthropic.model_request under it, so you sum one level or the other, never both. Compaction adds rather than repeats: gen_ai.usage.* leaves compaction tokens out, so on SDK spans you add anthropic.usage.compaction.input_tokens and anthropic.usage.compaction.output_tokens, and on API spans you add the anthropic.compaction spans. For a Managed Agents session, sum anthropic.model_request and anthropic.session.outcome_evaluation. Delivery is at least once, so deduplicate by trace id plus span id if your backend does not.
Then there is what the input number means. This is where the mock run earned its keep. The mock’s response said input_tokens: 120, with 2,000 cache writes and 5,000 cache reads beside it. The span said gen_ai.usage.input_tokens: 7120. In a Messages API response, input_tokens counts only the uncached part and total input is the sum of the three fields, which is why a logger that keeps only input_tokens loses the cached prefix entirely. The span does the addition for you: per the span reference, its input already includes cache reads and writes, and the docs warn against adding the cache attributes on top.
So a query that adds gen_ai.usage.input_tokens and gen_ai.usage.cache_read.input_tokens, a natural thing to write if you know the Messages API, counts the cached prefix twice. For cost, the single input number is the wrong shape, because the parts are priced differently: reads at a fraction of the base input price, 5-minute writes at 1.25 times it, 1-hour writes at twice it. On Messages API request spans, split it back:
uncached input = gen_ai.usage.input_tokens
- gen_ai.usage.cache_read.input_tokens
- gen_ai.usage.cache_write.input_tokens
cache writes by lifetime = anthropic.usage.cache_creation.ephemeral_5m_input_tokens
+ anthropic.usage.cache_creation.ephemeral_1h_input_tokensTreat a missing cache attribute as zero, not as null, or a SQL translation of this silently drops the row. The lifetime split exists only on Messages API spans, outcome evaluation spans carry no cache attributes at all, and compaction stays a separate line in the bill. On a streaming response’s SDK span, the 5-minute and 1-hour split holds only the writes made before the first output token, so for exact cache-write cost on streams, sum the API spans instead.
Why are Claude API OpenTelemetry spans not a billing record?
Because spans can go missing by design, and the docs send you elsewhere for accounting. Delivery is best effort, and a lost span is not sent again. A caller can drop a request’s spans with the sampled flag, as above. The docs’ advice for tracking each API key’s usage, for example to spot a leaked key, is the Usage API, which also counts requests that produced no spans.
The two tools answer different questions. The Usage API needs Admin API credentials, such as an Admin API key, and a workspace key will not do; its data typically lands within five minutes. Spans answer what the Usage API cannot: which feature or agent turn the tokens belong to, and why a run was slow or failed. A workable layout is to attribute tokens from spans and keep the Usage API as the total. Comparing the two only means something when both cover the same requests, the same time window and the same token definition, with your sampling rate and the reporting delay accounted for. A gap that grows with traffic at a steady sampling rate is expected; a change in the ratio is the thing to investigate, and it can be a dropped delivery, a sampler change or a client with tracing off just as well as real spend.
What does Full content mode send from Claude API traces?
It sends your prompts and the model’s responses to the backend, under that backend’s retention and access rules, up to 0.75 MB of content per span by default. Metadata only, the default, keeps identifiers, timing, status, token counts and the names you gave agents, tools and MCP servers. Two gaps that look like exporter bugs are not: in a Managed Agents thread the system instructions appear only on the first turn span, and a streamed SDK span carries the response only when you read it with messages.stream().
A few edges are not obvious from the setting’s name. With zero data retention you can still use Full content for the Messages API; the API holds the content in memory only until delivery. Managed Agents is not eligible for zero data retention, with or without telemetry. Organizations with a HIPAA, FERPA or K-12 restriction get metadata only from destinations whatever the setting, but SDK spans are not covered by that restriction, so a Full content SDK client in such an organization still exports prompts. And conversation_id, the SDK option that groups calls into a conversation, is exported in both modes, so it must not carry an email address or a customer name.
Where does Claude Code’s own OpenTelemetry fit next to the Claude API export?
It is a separate system, and the docs route Claude Code, the Agent SDK and Cowork to their own monitoring pages. Claude Code emits its own events and metrics with its own attribute names. On the same day, Claude Code 2.1.300 moved toward the shared vocabulary: per its release notes, trace spans gained GenAI semantic-convention attributes such as gen_ai.provider.name, gen_ai.conversation.id and error.type, and the api_request event gained cache_creation_5m_tokens and cache_creation_1h_tokens.
The cache split is spelled differently on the two sides, cache_creation_1h_tokens in Claude Code and anthropic.usage.cache_creation.ephemeral_1h_input_tokens in API spans, so one dashboard over both needs a mapping layer. The post on idle compaction and the one-hour prompt cache covers the Claude Code side, where the 1-hour write count measures the cache rewrite that follows a compaction, one part of what it costs.
Is Claude API OpenTelemetry worth turning on now?
My read: the SDK half, yes, today, in metadata-only mode, because it costs one tracer provider and gives you per-request tokens and latency inside traces you already have. Pin the version, write the token queries against CLIENT spans only until you add a destination, and use the uncached-input split from the first dashboard, because the first place a wrong sum shows up is a comparison with the invoice.
The API half earns its setup when you run server tools or Managed Agents with workflows that fan out, because that is where the work happens out of your process’s sight. Before you add it, decide two things: which source your token queries sum, since from that moment every request reports twice, and what your sampler does, since it now governs what Anthropic exports as well as what you keep.
01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Claude Code idle compaction: a cheap summary now or a full cache rewrite later
Five conditions decide whether idle compaction fires at all, and by default two of them leave out API-key sessions and most 200K-window ones. Who actually gets it, what the trade costs in cache tokens, and how to tune it or turn it off.
Gemini 4 Argon is public: the uses Google reports on day one, and the price that doubles on December 1
Seven uses Google reports for Argon, with the post each comes from, and the only one that shows its review record: six proposals, two shipped. The rest is the bill the announcement leaves to the docs, starting with how long decode counts input.
Claude Managed Agents dynamic workflows: the agent writes the fan-out, the budget has to come first
The switch is on by default with the new multiagent type, nothing approves the start of a run, and the one money cap can only be attached when the session is created. A setup guide built from the documentation, with the three settings that have to be decided before the first message.