Claude Code OpenTelemetry: Test the Export Locally First
The usual way to set up Claude Code telemetry is to point it at a collector, open the dashboard, and wait for claude_code.session.count to appear. When nothing appears you are debugging two systems at once. The faster path is a receiver you can read: a plain Node HTTP server that accepts OTLP over http/json, prints every export request, and keeps the raw payloads. No collector, no protobuf, no Docker.
This article is that receiver plus what it showed. Everything below was measured on 2026-09-18 with Claude Code 2.1.276 on Windows 11 and Node 24, running claude -p with the haiku model alias across ten runs. Total model spend was about $0.30 at list price.
The short answer
| Configuration | What reached the receiver |
|---|---|
| Defaults (metrics every 60 s, logs every 5 s) | Startup events after about 6 s, then everything else in a final flush 30 to 60 ms before the process closed |
| Both intervals at 1,000 ms | session.count about a second after start; cost, token and active-time metrics only after the API response |
OTEL_METRICS_EXPORTER=console with --output-format json | Nothing on stdout or stderr, nothing at the receiver; stdout stayed one valid JSON object |
Same, with --output-format text | 29,907 bytes of metric dumps on stdout, mixed with the answer |
otelHeadersHelper that exits 3 | One line on stderr, zero export requests, exit code 0 |
otelHeadersHelper that prints two headers | Both headers on every export request |
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 and OTEL_TRACES_EXPORTER=otlp | Two spans on /v1/traces at exit: claude_code.interaction and its child claude_code.llm_request |
OTEL_EXPORTER_OTLP_PROTOCOL unset | Zero requests, no stderr; one [ERROR] [3P telemetry] line in the debug log |
The environment variable DO_NOT_TRACK=1 was present in every run (it arrives through this machine's managed settings) and did not block any of this. The docs are right that it governs Anthropic's own operational telemetry, a separate path from the OTLP exporter you configure.
The receiver
OTLP has three protocol options and http/json is the one you can read without a library. The receiver below listens on port 4318, accepts the three signal routes, summarizes each request on one line and appends the raw payload to received.jsonl. It is dependency-free.
// otlp-sink.js: a dependency-free OTLP/HTTP JSON receiver for local testing.
const http = require('http');
const fs = require('fs');
const path = require('path');
const port = Number(process.argv[2] || 4318);
const outFile = path.join(__dirname, 'received.jsonl');
function summarize(route, body) {
const names = new Set();
let count = 0;
if (route === '/v1/metrics') {
for (const rm of body.resourceMetrics || [])
for (const sm of rm.scopeMetrics || [])
for (const m of sm.metrics || []) { names.add(m.name); count++; }
} else if (route === '/v1/logs') {
for (const rl of body.resourceLogs || [])
for (const sl of rl.scopeLogs || [])
for (const r of sl.logRecords || []) {
count++;
const a = (r.attributes || []).find(x => x.key === 'event.name');
names.add(a ? a.value.stringValue : '?');
}
} else if (route === '/v1/traces') {
for (const rs of body.resourceSpans || [])
for (const ss of rs.scopeSpans || [])
for (const s of ss.spans || []) { names.add(s.name); count++; }
}
return { count, names: [...names] };
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let body = null;
try { body = JSON.parse(raw); } catch (e) { body = null; }
const s = body ? summarize(req.url, body) : { count: -1, names: ['<not JSON>'] };
console.log(new Date().toISOString().slice(11, 23), req.method, req.url,
'len=' + (req.headers['content-length'] || 'chunked'),
'records=' + s.count, s.names.join(','));
fs.appendFileSync(outFile, JSON.stringify({
t: new Date().toISOString(), route: req.url, headers: req.headers, body
}) + '\n');
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{}');
});
}).listen(port, '127.0.0.1', () => console.log('listening on 127.0.0.1:' + port));
Start it with node otlp-sink.js, then run Claude Code in another shell with telemetry pointed at it. The protocol line is not optional: Claude Code has no default OTLP protocol, and the last row of the table above is what happens when you leave it out.
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
claude -p "Reply with the single word OK" --model haiku --output-format json
Our runs went through a Node runner that spawned claude with the prompt as an unquoted shell argument, so the prompt that reached Claude was the single word Reply. We know this because the user_prompt event reported prompt_length: 5, which is the kind of thing this receiver is for. The telemetry findings do not depend on the prompt.
What arrives, and when
A run with default intervals took 10.4 s wall clock and produced three export requests. The first was a batch of 13 startup events on /v1/logs, 6.1 s after the process started (the logs interval is 5 s, counted from the first record). The other two arrived 57 ms and 34 ms before the process closed: two more events (api_request and assistant_response), then all four metrics in one /v1/metrics request.
That final pair is the shutdown flush. The metric export interval defaults to 60 s and no run here lasted longer than 15 s, yet every OTLP run delivered its metrics. You do not need to lower OTEL_METRIC_EXPORT_INTERVAL for short headless jobs; a lower value only changes whether anything arrives before the process ends. With both intervals at 1,000 ms, claude_code.session.count came alone on the first tick, and the cost and token metrics still arrived only after the API response, because that is when they exist.
Every request carried Content-Type: application/json, a Content-Length header, and User-Agent: OTel-OTLP-Exporter-JavaScript/0.208.0. The Content-Length matters if your endpoint is Azure Monitor or anything else that rejects chunked bodies; the docs note that versions before 2.1.212 sent these requests chunked.
Resource and scope
All three signals shared one resource block: service.name claude-code, service.version 2.1.276, os.type windows, os.version 10.0.26200, host.arch amd64. Metrics came from scope com.anthropic.claude_code, events from com.anthropic.claude_code.events (both versioned 2.1.276), and spans from com.anthropic.claude_code.tracing at version 1.0.0.
Metrics
Four metrics were exported from a one-turn session, all monotonic sums with delta temporality (aggregationTemporality: 1):
| Metric | Unit | Datapoints | Extra attributes |
|---|---|---|---|
claude_code.session.count | none | 1 | start_type: fresh |
claude_code.cost.usage | USD | 1 | model, query_source: main |
claude_code.token.usage | tokens | 4 | type in input, output, cacheRead, cacheCreation |
claude_code.active_time.total | s | 1 | type: cli |
Every datapoint carried the standard attributes: session.id, user.id (a 64-hex installation identifier), organization.id, user.email, user.account_uuid, user.account_id, and terminal.type, which read mingw64 because the runner was spawned from Git Bash. app.version and app.entrypoint were absent by default; with OTEL_METRICS_INCLUDE_VERSION=true and OTEL_METRICS_INCLUDE_ENTRYPOINT=true they appeared as 2.1.276 and sdk-cli. The cost datapoint for our first run read 0.057752, which matched total_cost_usd in the JSON result on stdout exactly.
Events
The 13 startup events, in event.sequence order: managed_settings_resolved (new in 2.1.274, here with managed_settings.sources: ["remote"] and source_behavior: first-wins), hook_execution_start for SessionStart:startup, four hook_registered, five plugin_loaded, hook_execution_complete (3 of 3 succeeded in 568 ms), and user_prompt. Each log record's body is the event name string, such as claude_code.user_prompt, and the same name appears without the prefix in the event.name attribute.
The redaction defaults held. user_prompt carried prompt: "<REDACTED>" and prompt_length: "5"; assistant_response carried response: "<REDACTED>" and response_length: 377. Every plugin_loaded and plugin-sourced hook_registered record showed plugin.name: "third-party" with a plugin_id_hash, because the plugins on this machine come from an organization marketplace rather than the official one. With OTEL_LOG_USER_PROMPTS=1 the prompt attribute became the literal text Reply.
The api_request event is the one most people want. Ours carried model, input_tokens 10, output_tokens 528, cache_read_tokens 0, cache_creation_tokens 27551, cost_usd, cost_usd_micros 57752, duration_ms 7608, ttft_ms 1101, request_id, client_request_id, speed: normal and query_source: sdk. Note the two different query_source vocabularies: metrics use the category (main), events use the subsystem (sdk for a -p run, repl_main_thread interactively).
Traces
With the beta pair set, the /v1/traces request arrived in the shutdown flush alongside logs and metrics. It held a root claude_code.interaction span (4,423 ms, user_prompt: "<REDACTED>", user_prompt_length: 5, parent.source: none) and a child claude_code.llm_request span (4,341 ms) carrying the token counts, ttft_ms 660, first_content_ms 662, stop_reason: end_turn, the gen_ai.* semantic-convention duplicates, and query_source_safe: sdk. Both ended with status code 0, which is UNSET, matching the docs' statement that only failures set ERROR.
Three ways a setup exports nothing
Each of these ran with exit code 0 and a normal answer on stdout.
No protocol. With OTEL_EXPORTER_OTLP_PROTOCOL unset the receiver saw nothing and stderr was empty. The --debug flag wrote the reason to ~/.claude/debug/<session-id>.txt:
[DEBUG] [3P telemetry] Waiting for remote managed settings fetch before telemetry init
[DEBUG] [3P telemetry] Remote managed settings fetch settled, initializing telemetry
[DEBUG] [3P telemetry] isTelemetryEnabled=true (CLAUDE_CODE_ENABLE_TELEMETRY=1)
[DEBUG] [3P telemetry] getOtlpReaders: types=["otlp"], interval=60000, protocol=undefined, endpoint=http://127.0.0.1:4318
[ERROR] [3P telemetry] Telemetry init failed (remote settings path): Unknown protocol set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: undefined
The first two lines are worth knowing on their own: on a machine with remote managed settings, telemetry initialization waits for that fetch, because managed settings can override the destination.
A failing headers helper. We pointed otelHeadersHelper in the project's .claude/settings.json at a script that prints to stderr and exits
- Claude Code 2.1.275 added a startup warning for exactly this, and it
appeared on stderr of the -p run:
otelHeadersHelper failed (OpenTelemetry export headers unavailable): exited 3: helper deliberately failing
The receiver got zero requests. The failure is closed, not open: no export goes out without the headers, rather than an export with none. A helper that printed {"X-Lab-Token": "...", "Authorization": "Bearer from-helper"} put both headers on every request. On Windows the value runs through the shell, so a .cmd file with forward slashes in the path worked as the value.
The console exporter in JSON mode. OTEL_METRICS_EXPORTER=console is the docs' suggested debugging setup. Under --output-format json it printed nothing anywhere we could see, and stdout stayed a single JSON object. Under --output-format text the same configuration dumped 29,907 bytes of metric objects onto stdout ahead of the answer, starting with { descriptor: { name: "claude_code.session.count", type: "COUNTER". So the console exporter is not a debugging path for headless JSON runs, and it will corrupt any pipeline that parses text output. The receiver above is the substitute.
FAQ
Does Claude Code flush telemetry when a headless run exits?
Yes. In every OTLP run here the final /v1/logs and /v1/metrics requests landed 30 to 60 ms before the process closed, including metrics whose 60 s interval had never elapsed. A scheduled job that finishes in ten seconds still reports its cost.
Does DO_NOT_TRACK or DISABLE_TELEMETRY turn off OpenTelemetry export?
DO_NOT_TRACK=1 did not: it was set throughout our runs and every configured export arrived. We did not test DISABLE_TELEMETRY, but the docs describe both variables as controlling Anthropic's operational telemetry and feature-flag fetching, a separate path. Your OTLP exporter is enabled by CLAUDE_CODE_ENABLE_TELEMETRY=1 and the exporter selectors, and disabled by setting those selectors to none.
Where is the debug log for a claude -p run?
Pass --debug and read ~/.claude/debug/<session-id>.txt, where the session id is the one in the JSON result. Exporter failures are prefixed [3P telemetry]; lines prefixed [Anthropic telemetry] are the separate first-party path and do not indicate a problem with your collector.
Why does my endpoint reject Claude Code's exports with 411 or 400?
Check the Claude Code version. Every request we received carried a Content-Length header, which the docs say has been the behavior since 2.1.212; versions from 2.1.191 to 2.1.211 sent chunked bodies that Azure Monitor and similar endpoints reject.
Should I test with grpc or http/json?
Test with http/json because you can read it with the receiver above and inspect the payload with nothing but JSON.parse. Switch to whatever your collector prefers afterwards; the same environment variables apply. One limit to know: dynamic headers from otelHeadersHelper apply only to the http/protobuf and http/json protocols, not to grpc.
Is user.email really in the export?
Yes, on every metric datapoint and event when you are signed in with a Claude account, alongside organization.id and the account identifiers. It goes only to the endpoint you configure. If that is a concern, redact it in your collector pipeline; there is no variable to omit it on the client.
If the thing you are scheduling also depends on an MCP server, the same receiver pairs well with the status gate in our MCP timeout article, and the broader setup for unattended runs is in running Claude Code on a schedule.