Monitoring usage with OpenTelemetry
Export Claude Code metrics, events and traces over OpenTelemetry: setup, admin locking, every metric and event, privacy gates and SIEM auditing.
Claude Code can export its own telemetry to your observability stack over OpenTelemetry (OTel). You get three signals: metrics as time series (sessions, tokens, cost, lines changed), events through the logs protocol (each prompt, API call, tool run and permission decision), and, in beta, traces that tie a prompt to every API call and tool it triggered. Everything goes to the collector you configure, not to Anthropic.
I set this up for every client team larger than about ten people. It answers "who is using it, what does it cost, and what did it run?" in tools they already have, and it works on every provider, including Bedrock and Vertex where the Anthropic dashboards cannot see.
Five-minute start
Run a local collector (or use your vendor's OTLP endpoint), then:
# Turn telemetry on
export CLAUDE_CODE_ENABLE_TELEMETRY=1
# Pick signals: otlp, prometheus (metrics only), console or none
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
# Where to send it. There is no default protocol, so always set one
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.acme-health.internal:4318
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer otel_ingest_9f31"
# While testing, flush faster than the 60 s metrics default
export OTEL_METRIC_EXPORT_INTERVAL=10000
claude
How to tell it worked:
- Metrics: look for
claude_code.session.count, which is emitted when a session starts. - Logs only: send a prompt and look for a
claude_code.user_promptevent. - Nothing arriving: start
claude --debug-file /tmp/otel-debug.txt. Failures from your exporters are logged as[3P telemetry]errors. Lines tagged[Anthropic telemetry]are Anthropic's separate operational telemetry (see Data usage) and are not your problem.
Remember to put the export interval back to the default before rolling out.
Rolling it out to everyone
Put the variables in the env block of a managed settings file so every machine reports to the same place:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://otel-gw.platform.svc:4317",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer otel_ingest_9f31",
"OTEL_RESOURCE_ATTRIBUTES": "department=engineering,cost_centre=ENG-204"
}
}
Points that trip people up:
- Desktop. Code tab sessions in Claude Desktop read managed settings from the sources that reach Desktop sessions (see Desktop). The OpenTelemetry form under Monitoring in the claude.ai admin console's Data and privacy settings configures Cowork only; neither the CLI nor the Code tab uses it.
- Repositories cannot turn telemetry on. Exporter variables in
.claude/settings.jsonand.claude/settings.local.jsonare ignored, so a cloned repo cannot enable telemetry, redirect it or capture content. A repo can still switch a signal off by setting its selector (such asOTEL_LOGS_EXPORTER) tonone, unless managed settings, a--settingsfile or the launch environment sets that variable. - Child processes do not inherit
OTEL_*. Bash commands, hooks, MCP servers and language servers started by Claude Code do not see the exporter configuration. If an instrumented app you run through Bash should export its own telemetry, set its variables in the command.
How managed settings pin the destination
When an OTEL_EXPORTER_OTLP_* variable is set in managed settings, Claude Code strips conflicting developer-set variables at startup (with a debug log warning) so telemetry cannot be diverted:
| You set in managed settings | Claude Code removes from developer config |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Every per-signal endpoint |
OTEL_EXPORTER_OTLP_PROTOCOL | Every per-signal protocol |
OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_CLIENT_KEY or OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE | The per-signal versions of that variable, plus every endpoint variable, generic or per-signal, so the credentials cannot reach another collector |
Anything that decides where logs or traces go under detailed beta tracing (a generic or logs/traces endpoint or credential, an otelHeadersHelper, a logs or traces selector of none, console or empty, or telemetry switched off) | A developer-set BETA_TRACING_ENDPOINT (from v2.1.251). A metrics-only endpoint or credential does not trigger this |
Exporter selectors (OTEL_METRICS_EXPORTER, OTEL_LOGS_EXPORTER, OTEL_TRACES_EXPORTER) are not stripped. They follow normal precedence, so a developer can still turn a signal off or send it to the console; set the selectors in managed settings too if you need them locked. Across admin sources, OTEL_LOGS_EXPORTER follows the telemetry unit while the other two merge per key (v2.1.223+).
Per-signal variables you set in managed settings yourself are kept, which is how you send one signal elsewhere (the SIEM example does this). A per-signal credential in managed settings removes the developer-set endpoint for that signal. None of this changes what is collected, only where it goes. Before v2.1.217 each variable followed precedence independently, so a user-level per-signal endpoint could redirect a signal.
From v2.1.251 the same pinning applies when Claude Desktop or a self-hosted environment runner launches Claude Code with an OTLP endpoint in the environment it provides: the launcher's variables win over developer-set ones.
Configuration reference
Core variables
Per-signal endpoint and protocol variables replace the generic one for that signal. Per-signal headers are merged with the generic headers.
| Variable | Purpose | Default or example |
|---|---|---|
CLAUDE_CODE_ENABLE_TELEMETRY | Master switch, required | 1 |
OTEL_METRICS_EXPORTER | Comma-separated metrics exporters | otlp, prometheus, console, none |
OTEL_LOGS_EXPORTER | Comma-separated events exporters | otlp, console, none |
OTEL_EXPORTER_OTLP_PROTOCOL | Protocol for all signals. No default | grpc, http/json, http/protobuf |
OTEL_EXPORTER_OTLP_ENDPOINT | Collector for all signals | http://localhost:4317 |
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL | Per-signal protocol | as above |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | Per-signal endpoint | http://localhost:4318/v1/logs |
OTEL_EXPORTER_OTLP_HEADERS | Headers for every signal | Authorization=Bearer ... |
OTEL_EXPORTER_OTLP_METRICS_HEADERS, OTEL_EXPORTER_OTLP_LOGS_HEADERS | Extra per-signal headers | merged with the generic set |
OTEL_METRIC_EXPORT_INTERVAL | Metrics flush interval (ms) | 60000 |
OTEL_LOGS_EXPORT_INTERVAL | Events flush interval (ms) | 5000 |
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE | delta or cumulative | delta |
CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS | How often the dynamic headers helper reruns | 1740000 (29 minutes) |
Over http/protobuf and http/json, export requests carry a Content-Length header. (Versions v2.1.191 to v2.1.211 used chunked encoding, which Azure Monitor and similar endpoints rejected with 411 or 400.)
Content gates
By default no prompt text, response text, tool arguments or file contents leave the machine. Each of these opts in to more:
| Variable | What it adds |
|---|---|
OTEL_LOG_USER_PROMPTS=1 | Prompt text on user_prompt events and the interaction span |
OTEL_LOG_ASSISTANT_RESPONSES=1 | Response text on assistant_response events. Unset, it follows OTEL_LOG_USER_PROMPTS; set 0 to keep responses redacted while logging prompts |
OTEL_LOG_TOOL_DETAILS=1 | Tool parameters and input (Bash commands, MCP server and tool names, skill names, workflow names, file paths), real command names on user_prompt, and real agent, skill, plugin and MCP names on the cost and token counters |
OTEL_LOG_TOOL_CONTENT=1 | Tool output in the tool.output span event. Needs tracing |
OTEL_LOG_MANAGED_SETTINGS=1 | Redacted managed settings and a SHA-256 digest on managed_settings_resolved events (v2.1.274+). Ignored in project or local settings |
OTEL_LOG_RAW_API_BODIES=1 or =file:<dir> | Full Messages API request and response JSON as events, inline (truncated) or as untruncated files with a pointer. Implies everything the other gates reveal |
CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH | Truncation limit for content attributes, in UTF-16 code units (default 61440, 60 KB; v2.1.214+). A lower OpenTelemetry SDK attribute length limit wins |
There is one built-in exception: for Claude Desktop's own in-process MCP servers, in sessions Desktop owns, mcp_server_name and mcp_tool_name appear on tool_decision and tool_result even without OTEL_LOG_TOOL_DETAILS (v2.1.214+), because the host defines those names.
mTLS
| Protocol | Client certificate | Trust the collector's CA with |
|---|---|---|
http/protobuf, http/json | CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, optional CLAUDE_CODE_CLIENT_KEY_PASSPHRASE (see Network configuration) | NODE_EXTRA_CA_CERTS |
grpc | OTEL_EXPORTER_OTLP_CLIENT_KEY and OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE, or per-signal versions such as OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY | OTEL_EXPORTER_OTLP_CERTIFICATE |
The same configuration covers metrics, logs and traces.
Controlling metric cardinality
Every attribute on a metric is a label, and labels cost storage. These switches decide which ones are attached:
| Variable | Adds | Default |
|---|---|---|
OTEL_METRICS_INCLUDE_SESSION_ID | session.id, plus ccr.session.id on cloud sessions | true |
OTEL_METRICS_INCLUDE_VERSION | app.version | false |
OTEL_METRICS_INCLUDE_ACCOUNT_UUID | user.account_uuid and user.account_id | true |
OTEL_METRICS_INCLUDE_ENTRYPOINT | app.entrypoint | false |
OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES | Your OTEL_RESOURCE_ATTRIBUTES keys as datapoint labels | true |
OTEL_METRICS_INCLUDE_REPOSITORY | vcs.* repository identity on metrics and events (v2.1.269+) | false |
Dynamic headers
If your collector needs short-lived tokens, point otelHeadersHelper in settings at a script that prints a JSON object of string headers. It only applies to the HTTP protocols; with grpc, only the static header variables are used.
{ "otelHeadersHelper": "/opt/acme/bin/otel-headers" }
#!/usr/bin/env bash
token=$(vault read -field=token secret/otel/claude-code)
printf '{"Authorization":"Bearer %s","X-Tenant":"acme-health"}\n' "$token"
The value can be an executable path (spaces allowed) or a command line with arguments; on Windows it always runs through the shell, so quote paths with spaces. It runs at startup and then every 29 minutes unless you change CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS.
If the helper fails or prints invalid output, nothing is exported until it recovers. You will see otelHeadersHelper failed; telemetry is not being exported once per interactive session, a note in /status, details in the debug log (--debug or /debug), and a message on stderr in -p sessions.
Team and cost-centre attributes
OTEL_RESOURCE_ATTRIBUTES adds your own keys to every metric datapoint and event, as well as the OTLP resource block, so you can group by them directly in most backends:
export OTEL_RESOURCE_ATTRIBUTES="department=engineering,team.id=payments,cost_centre=ENG-204"
Custom keys never override built-in attributes such as user.id or session.id (except the vcs.* keys, below). To keep them in the resource block only, set OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES=false.
Warning: The format is strict: comma-separated
key=value, no spaces, and only US-ASCII without control characters, whitespace, double quotes, commas, semicolons or backslashes. Use underscores or camelCase (org.name=Acme_Health) or percent-encode (org.name=Acme%20Health). Quotes do not escape anything:org.name="Acme Health"gives a value containing the quote marks.
Traces (beta)
Tracing links each prompt to its API calls, tool runs and hooks as one trace. Enable it with CLAUDE_CODE_ENABLE_TELEMETRY=1 and CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 (ENABLE_ENHANCED_TELEMETRY_BETA also works), then choose a traces exporter. Traces reuse the common endpoint, protocol, header and mTLS settings.
| Variable | Purpose |
|---|---|
OTEL_TRACES_EXPORTER | otlp, console or none |
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL | Overrides the generic protocol |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Overrides the generic endpoint |
OTEL_EXPORTER_OTLP_TRACES_HEADERS | Merged with the generic headers |
OTEL_TRACES_EXPORT_INTERVAL | Span batch interval, default 5000 ms |
Spans redact prompt text, tool details and tool content unless the matching content gates are on.
Context propagation.
- Bash and PowerShell subprocesses receive a
TRACEPARENTvariable for the active tool execution span, so scripts can parent their own spans under Claude's trace. - Direct to the Anthropic API, each model request carries a W3C
traceparentfrom theclaude_code.llm_requestspan, and the API'straceresponseis recorded as a span link. HTTP MCP requests carrytraceparenttoo. Nothing is sent to third-party providers. - Behind a custom
ANTHROPIC_BASE_URL,traceparent(and the subprocessTRACEPARENT) is off by default because some proxies reject unknown headers. SetCLAUDE_CODE_PROPAGATE_TRACEPARENT=1to turn it on. - In Agent SDK and
-psessions, an inboundTRACEPARENT/TRACESTATEin Claude Code's own environment makes its interaction spans children of your trace. Interactive sessions ignore inbound values so they do not inherit stray CI context. - With an inbound
TRACEPARENTin those sessions, events also carrytrace_idandspan_id, even without a traces exporter. A record emitted during an interaction carries the interaction span's IDs; one emitted with no active interaction carries the inbound IDs.
Span tree.
claude_code.interaction one per prompt
├── claude_code.llm_request
├── claude_code.hook detailed beta tracing only
└── claude_code.tool
├── claude_code.tool.blocked_on_user
├── claude_code.tool.execution
└── subagent llm_request / tool spans (when the Agent tool spawns one)
If a PreToolUse hook defers a tool call, the deferred call's spans rejoin the original turn's trace when the session resumes. llm_request, tool.execution and hook spans set status ERROR on failure; others end UNSET. Every span carries the standard attributes plus span.type.
Key span attributes.
| Span | Notable attributes |
|---|---|
claude_code.interaction | user_prompt (gated), user_prompt_length, interaction.sequence, parent.source (env or none, v2.1.268+), interaction.duration_ms |
claude_code.llm_request | model, gen_ai.system (anthropic), gen_ai.request.model, query_source (detailed tracing only), query_source_safe (bounded, always present, v2.1.268+), agent_id, parent_agent_id, workflow.run_id, workflow.name (gated), speed, effort (v2.1.274+), llm_request.context, duration_ms, ttft_ms, first_content_ms, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, request_id, gen_ai.response.id, client_request_id, attempt, success, status_code, error, error_class, response.has_tool_call, stop_reason, gen_ai.response.finish_reasons. Each retry adds a gen_ai.request.attempt span event |
claude_code.tool | tool_name, tool_name_safe (no user-chosen names; most MCP tools become mcp_other), bash_command_class and bash_argv0 (from a fixed list), duration_ms, result_tokens, agent_id, parent_agent_id, workflow.*, tool_use_id, gen_ai.tool.call.id; with tool details on, file_path, full_command, skill_name, subagent_type |
claude_code.tool.blocked_on_user | duration_ms, decision, source |
claude_code.tool.execution | duration_ms, tool_use_id, gen_ai.tool.call.id, success, error (category, or full message with tool details), error_class |
claude_code.hook | hook_event, hook_name, num_hooks, hook_definitions (gated), duration_ms, num_success, num_blocking, num_non_blocking_error, num_cancelled |
Note that input_tokens excludes cache reads and writes, which are reported separately.
The tool.output span event. With OTEL_LOG_TOOL_CONTENT=1, successful Read and Bash calls (and, from v2.1.283, MCP tools, WebFetch and WebSearch) add a tool.output event to the tool span. Edit and Write need OTEL_LOG_TOOL_DETAILS=1 as well. Attributes are content (Read output, or what Write wrote), output (Bash's combined output, or the text an MCP tool or web tool returned, with images shown as placeholders), diff (Edit), plus file_path and bash_command with tool details. Nothing is recorded for errors, non-text Reads (images, PDFs, unchanged re-reads), other tools, or web calls moved to the background. Truncated attributes come with <attribute>_truncated and <attribute>_original_length.
Detailed beta tracing. The claude_code.hook span and a set of extra content attributes only appear with ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT, which also redirects logs and traces to that endpoint. Both are ignored in project and local settings, and interactive CLI sessions need your organisation allowlisted (SDK and -p sessions do not). The extra attributes, which are not part of the stable schema:
| Attribute | Span | Gate |
|---|---|---|
new_context | interaction (the prompt), llm_request (new messages and tool results) | OTEL_LOG_USER_PROMPTS |
new_context | tool (the result) | OTEL_LOG_TOOL_CONTENT |
system_reminders, system_prompt_preview (first 500 chars), user_system_prompt (once per session), response.model_output | llm_request | OTEL_LOG_USER_PROMPTS |
tool_input | tool | OTEL_LOG_TOOL_DETAILS |
With prompts logged, a claude_code.system_prompt event carries the complete system prompt the first time each distinct one is sent and again after compaction.
Example set-ups
Console output while debugging:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=console
export OTEL_METRIC_EXPORT_INTERVAL=2000
Prometheus scrape at http://localhost:9464/metrics:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=prometheus
With prometheus as the only metrics exporter, the USD, tokens and s units are dropped so the scrape stays valid Prometheus text (combinations like otlp,prometheus keep them). On a self-hosted environment runner, the session binds port 9464 only at capacity one; above that, the runner re-exposes session metrics on its own /metrics.
Metrics and events to different backends:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://metrics.acme-health.internal:4318
export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://logs.acme-health.internal:4317
For metrics only, leave OTEL_LOGS_EXPORTER unset; for events only, leave OTEL_METRICS_EXPORTER unset. Several exporters can be combined, for example OTEL_METRICS_EXPORTER=console,otlp.
Cloud sessions and Claude Tag
Cloud sessions, including Claude Tag channel sessions, run in cloud environments, so laptop settings do not reach them. Configure their telemetry in one of two places:
- Server-managed settings
envblock. This reaches users' machines and cloud sessions, but not Claude Tag channel sessions. - The cloud environment's variables. Applies only to sessions in that environment, and is the only route to Claude Tag sessions.
Anyone using an environment can read its variables, so do not put a collector token there, and a network secret will not help because Claude Code's own telemetry export never receives one. If the collector needs a credential, configure the whole export in server-managed settings, where setting a credential also strips endpoints set elsewhere.
Other constraints:
- Exports go through the session's network, so the collector's domain must be reachable at the environment's access level; add it to the environment allowlist if not. No server-managed setting can add domains.
- Claude Tag channels run in organisation-level environments, so change the shared environment set as default or pinned to the channel.
- Cowork does not receive server-managed settings and is configured separately.
Attribution. Cloud sessions carry session.id, ccr.session.id (the session's CLAUDE_CODE_REMOTE_SESSION_ID) and organization.id by default. Set OTEL_METRICS_INCLUDE_ENTRYPOINT=true and Claude Tag sessions show app.entrypoint of claude-in-slack. Set OTEL_RESOURCE_ATTRIBUTES alongside the other variables, not in the environment's setup script, whose exports end before Claude Code starts. In Claude Tag channel sessions Claude acts as the organisation's shared identity, so user.* does not tell you who tagged it.
Attributes on everything
Standard attributes
| Attribute | Meaning | Controlled by |
|---|---|---|
session.id | Session identifier | OTEL_METRICS_INCLUDE_SESSION_ID (on) |
ccr.session.id | Cloud session identifier | same |
app.version | Claude Code version | OTEL_METRICS_INCLUDE_VERSION (off) |
app.entrypoint | How it was launched: cli, sdk-cli, sdk-ts, sdk-py, claude-vscode, claude-in-slack | OTEL_METRICS_INCLUDE_ENTRYPOINT (off) |
organization.id | Organisation UUID when signed in | always, when available |
user.account_uuid, user.account_id | Account UUID and tagged ID (such as user_01...) when signed in | OTEL_METRICS_INCLUDE_ACCOUNT_UUID (on) |
user.id | Random anonymous install ID stored in ~/.claude.json; not derived from your account | always |
user.email | Sign-in email, or the cloud session's credentials | always, when available |
terminal.type | Such as iTerm.app, vscode, cursor, tmux | when detected |
Your OTEL_RESOURCE_ATTRIBUTES keys | Team, cost centre and so on | OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES (on) |
vcs.repository.url.full, vcs.owner.name, vcs.repository.name, vcs.provider.name | Repository identity | OTEL_METRICS_INCLUDE_REPOSITORY (off) |
In sessions signed in to a Claude apps gateway with /login, user.id is the IdP subject, user.email the signed-in email, user.groups the comma-separated IdP groups, and every export carries identity.source: gateway-oidc. This identity is applied last, so user.* and identity.* keys in OTEL_RESOURCE_ATTRIBUTES are ignored. Claude apps gateway configuration covers Desktop and Cowork sessions through a gateway.
Events (never metrics, to avoid unbounded cardinality) also carry prompt.id, workspace.host_paths (Desktop workspace folders), and, for agents inside a workflow run, workflow.run_id (prefixed wf_) and workflow.name (user-authored names become custom without tool details; v2.1.202+).
Repository attributes
With OTEL_METRICS_INCLUDE_REPOSITORY=true (v2.1.269+), Claude Code derives repository identity once per session from the origin remote. HTTPS and SSH remotes on GitHub, GitLab and Bitbucket Cloud give identical results.
| Attribute | Example |
|---|---|
vcs.repository.url.full | https://github.com/acme-health/claims-api (no .git) |
vcs.owner.name | acme-health; omitted for single-segment paths |
vcs.repository.name | claims-api |
vcs.provider.name | github, gitlab, bitbucket or gitea when recognised |
Values are lowercased and never include credentials, query strings or fragments. They are omitted with no origin, a non-URL remote, or when the only repository is your home directory. A vcs.* key in OTEL_RESOURCE_ATTRIBUTES overrides the derived value; declaring vcs.repository.url.full stops Claude Code reading the remote at all, which is the fix when a self-hosted server gives HTTPS and SSH clones different paths. For cloud sessions, set the variable on the cloud environment and allow your collector's domain. Anthropic's own telemetry drops all vcs.* keys.
Metrics
| Metric | What it counts | Unit | Extra attributes |
|---|---|---|---|
claude_code.session.count | Sessions started | none | start_type: fresh, resume, continue, or agents_view (the claude agents dashboard process, not a conversation) |
claude_code.lines_of_code.count | Lines added or removed | none | type (added, removed), model |
claude_code.pull_request.count | PRs or MRs created via shell or MCP | none | none |
claude_code.commit.count | Git commits created | none | none |
claude_code.cost.usage | Estimated cost per API request | USD | see below |
claude_code.token.usage | Tokens per API request | tokens | type (input, output, cacheRead, cacheCreation) plus the cost attributes |
claude_code.code_edit_tool.decision | Accept or reject of Edit, Write, NotebookEdit | none | tool_name, decision, source, language (unknown for unrecognised extensions) |
claude_code.active_time.total | Active (non-idle) time | s | type: user (keyboard) or cli (tools and responses) |
Cost and token attributes: model; query_source (main, subagent, auxiliary); speed (fast only when fast mode was used); effort (low, medium, high, xhigh, max, absent when none was sent); and attribution fields agent.name, skill.name, plugin.name, marketplace.name, mcp_server.name, mcp_tool.name.
Attribution redaction without OTEL_LOG_TOOL_DETAILS:
agent.name: built-in and official-marketplace agents verbatim; other user-defined agents becomecustom.skill.name: built-in, bundled, user-defined and official-marketplace skills verbatim; third-party plugin skills becomethird-party.plugin.name: official-marketplace verbatim; third-party becomesthird-party.marketplace.name: only ever emitted for official-marketplace plugins.mcp_server.nameandmcp_tool.name: built-in, claude.ai-proxied and official-registry servers verbatim; user-configured becomecustom. Present only on requests that consumed an MCP tool result (before v2.1.222 they stuck to every later request, so expect a step down in dashboards after upgrading).
Before v2.1.273 these stayed redacted even with tool details on. The token input type excludes cache reads and writes.
Events
Events need OTEL_LOGS_EXPORTER set. Every event carries the standard attributes plus event.name, event.timestamp (ISO 8601) and event.sequence.
Correlation
| Attribute | Use |
|---|---|
prompt.id | UUID shared by everything one user prompt caused. Filter by it to see the prompt, its API calls and its tool results together |
event.sequence | 0-based counter per Claude Code process, not per session. It survives /clear (which issues a new session.id), and a resumed session takes the resuming process's numbers, so sort by event.timestamp first and use the sequence only to break ties |
message.uuid | Transcript message UUID (~/.claude/projects/*/*.jsonl) on assistant_response, api_response_body and most user_prompt events (v2.1.214+; v2.1.274+ on response bodies) |
request_id | Server request ID from the request-id header, or x-amzn-requestid on Bedrock (v2.1.282+). On the API events and responses; matches the span attribute |
client_request_id | Client-generated x-client-request-id, on api_request and api_error for first-party connections. Useful for timeouts that never got a server ID (v2.1.214+) |
Joining events to transcripts (message.uuid, request_id as requestId, tool_use_id) works, but the transcript format is internal and changes between versions, so treat any such join as version-specific.
Conversation and API events
| Event | When | Attributes beyond the common set |
|---|---|---|
claude_code.user_prompt | A prompt is submitted, including turns Claude Code starts itself | prompt_length; prompt and prompt_text (same value, both redacted unless prompts are logged; prompt_text exists for backends that nest dotted names, v2.1.287+); message.uuid; command_name (built-ins as typed; custom, plugin and MCP become custom or mcp without tool details); command_source (builtin, custom, mcp) |
claude_code.assistant_response | A response contains text (thinking and tool-use blocks excluded) | response_length; response (redacted by default); model; request_id; message.uuid; query_source |
claude_code.api_request | Each API request | model, cost_usd, cost_usd_micros (integer), duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, request_id, client_request_id, speed, query_source, effort, attribution fields |
claude_code.api_error | A request fails (after retries) | model, error, status_code (absent for non-HTTP), duration_ms, attempt, request_id, client_request_id, speed, query_source, effort, attribution fields |
claude_code.api_refusal | A response ends with stop_reason: "refusal" (not an HTTP error, so api_error does not fire) | model, request_id, query_source, speed, attempt, effort, server_fallback_hop (true when the server already retried on another model), has_category, has_explanation, category (cyber, bio, frontier_llm, reasoning_extraction; only with tool details), attribution fields |
claude_code.api_retries_exhausted | A request fails after more than one attempt, alongside the final api_error | model, error, status_code, total_attempts, total_retry_duration_ms, speed |
claude_code.api_request_body | Each attempt, with raw bodies on | body (inline) or body_ref (file mode), body_length, body_truncated, model, query_source, request_body_id (v2.1.274+). Thinking in earlier turns is redacted |
claude_code.api_response_body | Each successful response, with raw bodies on | body or body_ref, body_length, body_truncated, model, query_source, request_id, request_body_id, message.id, message.uuid. Thinking is redacted |
claude_code.compaction | Compaction finishes | trigger (auto, manual), success, duration_ms, pre_tokens, post_tokens, error, precompute_reuse on manual runs (hit, miss_custom_instructions, miss_hook, miss_not_ready) |
claude_code.subagent_completed | A subagent returns | agent_type (custom names become custom without tool details), agent.source, is_built_in, is_async, total_tokens (final request only, not a run total), total_tool_uses, duration_ms, model, final_model, model_swapped, plugin_id_hash, plugin.name |
claude_code.at_mention | An @ mention resolves (some early exits log nothing) | mention_type (file, directory, agent, mcp_resource, peer), success |
claude_code.feedback_survey | A session survey is shown or answered | event_type, appearance_id, survey_type, response, enabled_via_override (boolean) |
In raw-body file mode, each successful response also appends a line to <dir>/index.jsonl with timestamp, session_id, query_source, model, request_id, message_id, message_uuid, request_file and response_file (v2.1.274+). Request files are <uuid>.request.json; response files are <request_id>.response.json.
For subagent cost, use the token and cost counters filtered to query_source of subagent rather than subagent_completed, whose total_tokens covers only the final request. That category also includes agent-based hooks, which emit no subagent event.
Tool and permission events
claude_code.tool_result: a tool finished (rejected calls do not produce one). Attributes: tool_name, tool_use_id (matches hook payloads), success, duration_ms, error_type, decision_type (always accept), decision_source (config, hook, user_permanent, user_temporary), tool_input_size_bytes, tool_result_size_bytes, mcp_server_scope. With tool details: error (full message), tool_parameters, tool_input (values over 512 characters truncated, about 4K characters in total), and for a successful git commit from Bash or PowerShell, vcs.ref.head.revision, vcs.ref.head.name and vcs.ref.head.type (v2.1.269+).
tool_parameters contents by tool:
- Bash:
bash_command,full_command,timeout,description,dangerouslyDisableSandbox, plusgit_commit_idandgit_branchafter a successful commit. The desktop workspace Bash tool reports only the first three. - MCP tools:
mcp_server_name,mcp_tool_name. - Skill:
skill_name. - Agent (or legacy Task):
subagent_type.
claude_code.tool_decision: a permission decision. Attributes: tool_name (user-configured MCP tools always show as mcp_tool), tool_use_id, decision (accept, reject), tool_source (builtin, mcp, sdk_host_builtin_mcp; v2.1.214+), source, and tool_parameters with tool details (same shape as above, minus commit fields; useful for seeing exactly what was rejected).
What source means:
| Value | Meaning |
|---|---|
config | Decided without prompting: settings rules, managed policy, --allowedTools/--disallowedTools, the permission mode, a session grant from an earlier prompt, or an inherently safe tool. Also used when the prompt mechanism itself fails (an invalid canUseTool or --permission-prompt-tool result, or the input stream closing) |
hook | A PreToolUse or PermissionRequest hook decided |
user_permanent | The user chose "Yes, and don't ask again". In the interactive CLI only that choice reports this; later matches report config. In SDK and -p sessions later matches report it too |
user_temporary | One-time "Yes", or a rest-of-session grant on a file prompt. Same interactive/SDK split |
user_abort | Prompt dismissed without an answer, including an interrupt while an SDK permission request is pending |
user_reject | The user chose "No". Interactive deny-rule matches report config; in SDK and -p sessions they report user_reject |
claude_code.permission_mode_changed: from_mode, to_mode (default, plan, acceptEdits, auto, bypassPermissions), trigger (shift_tab, exit_plan_mode, auto_gate_denied, auto_opt_in; absent for SDK or bridge changes).
Session, auth and extension events
| Event | When | Attributes |
|---|---|---|
claude_code.auth | /login or /logout completes | action, success, auth_method, error_category (never the raw message), status_code |
claude_code.mcp_server_connection | An MCP server connects, disconnects or fails | status, transport_type, server_scope, duration_ms, error_code, is_plugin, plugin_id_hash, plugin.name (third-party redacted); with tool details, server_name and error |
claude_code.internal_error | An unexpected internal error is caught (not on Bedrock, Agent Platform or Foundry, or with DISABLE_ERROR_REPORTING) | error_name, error_code. Never the message or stack |
claude_code.plugin_installed | claude plugin install or /plugin finishes | marketplace.is_official, install.trigger (cli, ui); for third-party marketplaces plugin.name, plugin.version and marketplace.name need tool details |
claude_code.plugin_loaded | Once per enabled plugin at startup | plugin.name, marketplace.name, plugin.version, plugin.scope (official, community, org, user-local, default-bundle), enabled_via (default-enable, org-policy, admin-install, seed-mount, user-install), plugin_id_hash, has_hooks, has_mcp, host_owned_mcp, skill_path_count, command_path_count, agent_path_count, safe_mode |
claude_code.skill_activated | A skill runs, by Claude or as a / command | skill.name (custom and third-party become custom_skill), invocation_trigger (user-slash, claude-proactive, nested-skill), skill.source, skill.kind (workflow), plugin.name, marketplace.name |
claude_code.hook_registered | Once per configured hook at startup | hook_event, hook_type (command, prompt, mcp_tool, http, agent), hook_source (userSettings, projectSettings, localSettings, flagSettings, policySettings, pluginHook), safe_mode, hook_matcher (gated), plugin.name, plugin_id_hash |
claude_code.hook_execution_start | Hooks begin for an event | hook_event, hook_name (such as PreToolUse:Write), num_hooks, managed_only, hook_source (policySettings or merged), safe_mode, hook_definitions (needs detailed tracing and tool details) |
claude_code.hook_execution_complete | All hooks for an event finish | The start attributes plus num_success, num_blocking, num_non_blocking_error, num_cancelled, total_duration_ms, and from v2.1.280 stdout_chars, additional_context_chars, system_message_chars, initial_user_message_chars, num_outputs_persisted |
claude_code.hook_plugin_metrics | An official-marketplace plugin hook reports metrics | plugin_id (name@marketplace), hook_event, up to 20 plugin keys matching ^[a-z][a-z0-9_]{0,39}$ with boolean or number values |
plugin_id_hash is a deterministic hash of plugin name and marketplace (for plugins synced from claude.ai, the marketplace name claude.ai reports, or synced), so you can count distinct third-party plugins without recording names. It and plugin.name go only to your exporter. safe_mode reflects --safe-mode (v2.1.169+), in which plugins report inventory but do not load.
Retention sweep event
claude_code.retention_sweep (v2.1.227+) is emitted once per run of the clean-up that deletes data older than cleanupPeriodDays. The sweep runs in the background at most once per session; if another session on the machine swept in the last 24 hours, this one waits at least 10 minutes, so short sessions emit nothing. claude -p --bare never sweeps.
| Attribute | Meaning |
|---|---|
result | complete or skipped |
period_days | Retention in force (30 if unset) |
used_default | true when no readable source sets cleanupPeriodDays |
skip_reason | user_source_disabled (user settings excluded and nothing else sets it), settings_unknowable (a settings file could not be read), settings_invalid_key_set (validation errors and the key is set) |
transcripts_deleted | Top-level transcript files removed |
transcripts_exempted_desktop | Old transcripts kept under the Desktop and Cowork rule (v2.1.248+) |
session_files_deleted | Transcripts plus per-session companion files |
artifacts_deleted | Total items removed; treat as a floor |
files_retained_fresh | Files still in retention; also a floor |
files_past_cutoff | Old files the sweep could not delete, plus stale skills/synced/ or plugins/synced/ folders |
error_count | Errors while listing or deleting |
A cleanupPeriodDays in managed settings lets the sweep run even when a lower-priority settings file is broken; a broken managed-settings.json still pauses it unless another managed source supplies the value. Counters are only present on complete results.
Managed settings resolved event
claude_code.managed_settings_resolved (v2.1.274+) records which managed policy a session ended up with: at startup, whenever the policy or its helper changes, and when a policy stops the session. It is how I find laptops on the wrong policy source or with a broken policy helper.
| Attribute | Meaning |
|---|---|
managed_settings.trigger | startup, change (only when something differs from the last event), or refused |
error.type | On refused only: helper_failed, policy_invalid, provider_not_allowed (v2.1.285+), consent_rejected, force_refresh_failed, gateway_rejected (a Claude apps gateway returned 403), version_below_minimum (outside requiredMinimumVersion/requiredMaximumVersion), _OTHER |
managed_settings.sources | Array of sources delivering policy keys, highest priority first: remote, plist, hklm, file, parent (an embedding host), hkcu |
managed_settings.source_behavior | first-wins or merge |
managed_settings.helper.state | ok; a failure such as bad_path, not_a_file, exit_nonzero, timed_out, oversize, parse_failed, envelope_invalid, schema_rejected; or none |
managed_settings.helper.applied | output or none |
managed_settings.helper.entry, managed_settings.helper.path | policyHelper and its configured path, when one is selected |
managed_settings.resolved_sha256 | With OTEL_LOG_MANAGED_SETTINGS=1: digest of the unredacted policy (keys sorted, no whitespace). Same digest, same policy |
managed_settings.settings, managed_settings.settings_truncated | With the opt-in: the policy's shape as JSON with values redacted, cut at 8 KB |
Redaction keeps names, booleans, numbers and fixed-choice strings (such as permissions.defaultMode), and replaces every other string, URL, command and env value with "[REDACTED]". Permission rules keep their tool name, as in Bash([REDACTED]), for built-in tools and mcp__ references. OTEL_LOG_MANAGED_SETTINGS cannot be enabled from project or local settings, and server-managed settings can set it without the approval dialog. In an untrusted folder, the refusal event is not exported.
Turning the data into answers
Usage and cost
| Question | Use |
|---|---|
| Who uses it and how often | claude_code.session.count, claude_code.active_time.total |
| Where tokens go | claude_code.token.usage by type, user, team, model, skill.name, plugin.name, agent.name |
| Output | claude_code.lines_of_code.count by model, claude_code.commit.count, claude_code.pull_request.count |
| Spend by team or feature | claude_code.cost.usage with your resource attributes and the attribution fields |
Cost metrics are estimates; your provider's billing is authoritative. Each streaming response is counted exactly once, even when a gateway streams usage across several frames (before v2.1.214, multi-frame usage inflated cost and token counts).
model is available on the token, cost and lines-of-code metrics. Commits have no model, so approximate by joining to the token or cost metric on session.id, filtering that side to query_source of main so subagent and auxiliary calls do not skew the attribution.
Good alerts to start with: cost spikes, unusual token consumption, and unusually high session counts from a single user.
Spotting retry exhaustion
Claude Code retries internally and emits one api_error when it gives up, with attempt holding the total. CLAUDE_CODE_MAX_RETRIES defaults to 10 and caps at 15; CLAUDE_CODE_RETRY_WATCHDOG (v2.1.199+) raises the default and removes the cap. So an exhausted transient error shows attempt of 11 by default and at most 16 without the watchdog. Lower values mean a non-retryable error (such as a 400) or a cause with its own smaller budget, such as failing to load AWS or Google Cloud credentials (retried at most twice). To tell recovery from a stall, check for a later api_request in the same session.id.
Tool patterns and performance
From tool_result events: most-used tools, success rates, average duration, and error types per tool. From api_request and tool events: latency hot spots.
Mapping to GenAI semantic conventions
Claude Code does not set gen_ai.usage.*. Its input_tokens (span, event, and the metric's input type) excludes cache tokens, whereas the conventions' gen_ai.usage.input_tokens includes them. To compute the convention value, add input, cache read and cache creation. Cache reads map to gen_ai.usage.cache_read.input_tokens and cache creation to gen_ai.usage.cache_write.input_tokens (older conventions call it gen_ai.usage.cache_creation.input_tokens).
Security auditing
OTel events are Claude Code's audit trail. Send them via the logs exporter to any SIEM with an OTLP receiver, or through an OpenTelemetry Collector.
Who did it
Events carry user.email, user.account_uuid, user.account_id and organization.id when signed in with a Claude account (or in a cloud session with its own credentials), plus user.id and session.id. Claude Code does not act under a service account: in a developer's session, MCP calls, Bash commands and edits are attributed to that developer, or to their IdP identity in a Claude apps gateway session. Claude Tag channel sessions use the organisation's shared identity.
With a direct API key, Bedrock, Agent Platform or Foundry there is no Claude account, so only user.id and session.id are populated. Add identity yourself, per user, through managed settings or a launch wrapper:
export OTEL_RESOURCE_ATTRIBUTES="enduser.id=priya.shah@acme-health.co.uk,enduser.directory_id=EMP-10442"
MCP activity
Turn on the logs exporter and OTEL_LOG_TOOL_DETAILS=1 for full detail:
| Event | What you get for MCP |
|---|---|
mcp_server_connection | Connects, disconnects and failures with server_name, transport_type, server_scope and error detail |
tool_result | Each call, with server and tool names in tool_parameters and arguments in tool_input |
tool_decision | Allowed or denied, and whether config, a hook or the user decided |
Without the flag, tool_name is the literal mcp_tool for user-configured servers, arguments are omitted, and connection events drop server_name and the error message but keep is_plugin, plugin_id_hash and a redacted plugin.name.
Detection cheat sheet
| What you want to detect | Event | Attributes |
|---|---|---|
| A tool call allowed or denied, and by what | tool_decision | decision, source, tool_name, tool_parameters |
| Permission mode escalation | permission_mode_changed | from_mode, to_mode, trigger |
| A policy hook blocked something | hook_execution_complete | hook_event, num_blocking |
| Sign-in and auth failures | auth | action, success, error_category |
| MCP connects and failures | mcp_server_connection | status, server_name, is_plugin, error_code |
| Plugin installs and their source | plugin_installed | plugin.name, marketplace.name, marketplace.is_official |
| Commands run and files touched | tool_result or tool_decision, with tool details | tool_parameters, tool_input |
| Policy source, helper health, refusals | managed_settings_resolved | trigger, sources, source_behavior, helper.state, error.type |
Claude Code emits raw events only. Baselining, anomaly detection, cross-session correlation and alerting are your SIEM's job.
Egress paths, controls and evidence
| Path off the machine | Managed controls | Events |
|---|---|---|
| Bash and PowerShell | sandbox.enabled, sandbox.failIfUnavailable, sandbox.allowUnsandboxedCommands, sandbox.network.allowManagedDomainsOnly, sandbox.network.allowedDomains | tool_decision, tool_result |
| MCP servers | allowedMcpServers, allowManagedMcpServersOnly, deniedMcpServers, managed MCP | mcp_server_connection, tool_decision, tool_result |
| Hooks | allowManagedHooksOnly, allowedHttpHookUrls | hook_registered, hook_execution_start, hook_execution_complete |
| Plugins | strictKnownMarketplaces, disableSideloadFlags, syncClaudeAiPlugins, syncClaudeAiSkills | plugin_installed, plugin_loaded |
| WebFetch | permissions.deny, allowManagedPermissionRulesOnly | tool_decision, tool_result |
| Uploads to claude.ai, such as Artifact | permissions.deny, enableArtifact | tool_decision, tool_result |
| Remote Control | disableRemoteControl | none dedicated |
| Local transcript retention | cleanupPeriodDays | retention_sweep |
The settings reference documents each key. Caveats: allowedHttpHookUrls merges across settings files, so developers can add to an empty managed list (allowManagedHooksOnly decides what runs); hook events fire once per hook event, not per hook, and an HTTP hook's URL appears only in hook_definitions, which needs detailed tracing. For what Claude Code itself sends to Anthropic, see Data usage.
Warning:
OTEL_LOG_TOOL_DETAILS=1puts commands, server names and tool input in your telemetry, which can be as sensitive as the session itself. Only enable it where your collector is approved to hold that content.
Verifying retention
Set cleanupPeriodDays in managed settings, collect retention_sweep, and cast period_days and the counters from strings to numbers before comparing:
| What you see | Meaning |
|---|---|
result = skipped | Sweep paused; read skip_reason |
used_default = true, or period_days not your value | Your managed value is not applied |
error_count > 0 | Errors while deleting; old data may remain |
files_past_cutoff > 0 | Old files survived (or stale synced folders were found); read with error_count |
| No event | Not a failure by itself: nobody launched Claude Code, a session is still open, or it ended before the sweep finished |
The sweep does not cover every path; The .claude directory lists what remains and how to clear it.
Shipping events to a SIEM
Events only, full tool detail, straight to an OTLP receiver:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_LOG_TOOL_DETAILS": "1",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "http/protobuf",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "https://siem-ingest.acme-health.co.uk:4318/v1/logs",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer siem_ingest_44a0"
}
}
Send a prompt and look for claude_code.user_prompt in the SIEM; if nothing appears, check a --debug-file log for [3P telemetry] errors.
Choosing backends
- Metrics: time-series databases for rates and aggregates; columnar stores for complex queries and unique-user counts; full observability platforms for visualisation and alerting. If you need DAU, WAU or MAU, pick something good at distinct counts.
- Events: log aggregation for search; columnar stores for structured analysis; observability platforms to correlate with metrics.
- Traces: a distributed tracing system for waterfalls and latency, or a platform that links traces to metrics and logs.
Resource attributes
Every export carries:
service.name:claude-codefor terminal sessions,claude-code-desktopfor Code tab sessions. If your pipelines filter onclaude-code, add the Desktop name too.service.version: Claude Code version (or the Desktop version for Code tab sessions).os.type,os.version,host.arch, andwsl.versionunder WSL.- Meter name
com.anthropic.claude_code.
Anthropic publishes a Claude Code monitoring guide on GitHub (anthropics/claude-code-monitoring-guide) with Docker Compose, Prometheus and OpenTelemetry set-ups and report templates for measuring ROI. AWS publishes Bedrock-specific monitoring guidance in its "guidance for Claude Code with Amazon Bedrock" sample repository.
Privacy summary
- Export is opt-in and goes only to the endpoint you configure. Anthropic's own operational telemetry is separate; see Data usage.
- Metrics and events never contain raw file contents or code snippets. Trace spans are a separate path governed by
OTEL_LOG_TOOL_CONTENT. - With OAuth sign-in,
user.emailis included, sent only to your endpoint. Filter it in your backend if that matters to you. - Prompts are recorded as length only unless
OTEL_LOG_USER_PROMPTS=1. Then the prompt appears in bothpromptandprompt_textonuser_promptevents, so a collector rule that drops it must name both:
processors:
attributes/strip-prompts:
actions:
- key: prompt
action: delete
- key: prompt_text
action: delete
With tracing, the interaction span's user_prompt also carries it, and detailed tracing adds messages, tool results, system reminders, system prompt text and model output.
- Responses are length only unless
OTEL_LOG_ASSISTANT_RESPONSES=1(or it falls back to the prompts flag). Under detailed tracing,response.model_outputfollows the prompts flag regardless. - Tool arguments need
OTEL_LOG_TOOL_DETAILS=1. Thentool_parameters(untruncatedfull_commandincluded) andtool_inputappear, plus real command and attribution names. Arguments can contain secrets, so redact in the backend. - Tool output in spans needs
OTEL_LOG_TOOL_CONTENT=1, truncated per attribute at the content limit. - Raw API bodies need
OTEL_LOG_RAW_API_BODIESset in your shell, user settings or managed settings (never project or local). They contain the full conversation; extended thinking is always redacted. Usefile:<dir>for untruncated files and ship that directory with a log collector rather than the telemetry stream.