Skip to main content

Telemetry data model

Analysis of the two raw data sources Claude Code produces — OpenTelemetry (OTel) logs/metrics and local session transcripts — independent of how this project currently consumes them (that part is documented in What telemetry can tell you and the code itself). Covers what each source is, how granular it gets, how "session" is defined, the cardinality between sessions/prompts/API calls, how often each stream fires, what the metric fields actually mean, and how much of the claude_code.* schema is actually a shared cross-vendor OTel standard versus Claude-Code-proprietary.

1. The two data sources​

OTel (logs + metrics)Session transcripts
NatureLive push, only captured while a receiver is listeningWritten to disk by Claude Code regardless of telemetry settings
LocationStreamed over OTLP/HTTP~/.claude/projects/<sanitized-cwd>/<session_id>.jsonl
FormatGeneric OTLP wire protocol; the specific claude_code.* events/attributes are Claude-Code-proprietary, not the shared GenAI semantic convention (see §9)Internal, undocumented, no stability guarantee
ContentAttributes only (model, token counts, dimensions)Full conversation content — prompts, thinking blocks, tool calls & results, plus the same usage numbers

2. Level of detail​

OTel logsOTel metricsTranscripts
Granularity1 event per real model API callperiodic aggregate counters (~60s)per API call (see cardinality below), plus every prompt/response/tool-call
Conversation contentnonoyes
Token fieldsinput_tokens, output_tokens, cache_read_tokens, cache_creation_tokensn/asame fields, plus thinking_tokens, 1h/5m cache split, service_tier, per-iteration breakdown
Productivity datanoLOC added/removed, active time, commit/PR counts, Claude Code's own cost calca cost-state entry with running totals per session
Differentiates thinking/tool-use/text?nonoyes, as separate content-block types — but not as separate cost/token figures (see §4)

3. Session definition​

  • A session = one continuous Claude Code process/conversation run, identified by a single UUID (session_id), generated at start.
  • The same session_id is used by both data sources — it's the transcript's filename, and it's the session.id attribute stamped on every OTel log/metric event. This is what makes cross-referencing between the two possible at all.
  • Every transcript entry additionally carries an entrypoint field ("cli" vs "claude-vscode"), distinguishing a terminal invocation from the VS Code extension — but this is transcript-only metadata; OTel events carry no equivalent field.

4. Cardinality / hierarchy​

Session (1)
└─ User prompt (n)
└─ Model API call (n) ← atomic unit of usage/cost — matches message.id
└─ Content block (n) ← thinking | text | tool_use×n (transcript only)
  • One user prompt can trigger many real API calls (an agentic tool loop: respond → tool executes → result appended → model called again).
  • One API call (message.id) can itself contain multiple content blocks — e.g. two parallel tool calls in one response.
  • OTel logs align exactly to the API-call level (message.id), not to content blocks and not to user prompts. In one sampled transcript, 19 raw transcript lines corresponded to only 8 real API calls — the transcript writer splits one API response into several lines by content-block type, all sharing the same message.id/usage.
  • Important nuance: even the transcript doesn't subdivide cost below the API-call level. usage is a whole-response property, duplicated verbatim across every content-block line that response was split into. Neither data source can say "N tokens were spent thinking vs. calling a tool" — that breakdown doesn't exist anywhere, because Anthropic's API itself only reports usage per response.
  • Consequently, "an output" (e.g. "several changed files") maps to however many API calls it took: multiple tool calls within one response still cost one usage record; the same edits spread across several agentic turns cost several.

5. How often OTel fires​

  • Logs (claude_code.api_request): event-driven, exactly once per real API call (per distinct message.id).
  • Metrics: time-driven, not tied to any output — exported at most every 60 seconds regardless of how many API calls/tool uses happened in between, as deltas by default. Cumulative payloads must be diffed against the previous value to get a delta; Costpilot does that on ingestion.

6. Metric field definitions​

Per Anthropic's official Claude Code OTel metrics documentation:

  • claude_code.active_time.total — type attribute: "user" (keyboard interactions) vs. "cli" (tool execution / AI response generation). Not related to CLI-vs-VS-Code entrypoint, despite the similar-looking value.
  • claude_code.lines_of_code.count — type: "added" / "removed".
  • claude_code.cost.usage — richest attribute set: model, query_source ("main" / "subagent" / "auxiliary"), effort, speed, agent.name, skill.name, plugin.name, marketplace.name, mcp_server.name, mcp_tool.name.

7. What level metrics attach to​

Not uniformly "per session." Session is the floor dimension (every data point carries session_id), but each metric type adds its own further dimensions on top:

MetricDimensions beyond session
commit.count / pull_request.countnone
active_time.totaltype
lines_of_code.countmodel, type
cost.usagemodel, query_source, effort, and optionally agent.name / mcp_server.name+mcp_tool.name / skill.name

So the true cardinality is: session : n periodic ticks (~60s) : m dimension-combinations active in that tick, where m varies per metric type — flat for commits/PRs, potentially several rows per tick for cost.usage (e.g. one for the main model loop, a separate one for an auxiliary subagent call active in the same window).

8. OTel attribute mutability​

General Claude Code behavior, independent of any consuming project:

  • Resource attributes (OTEL_RESOURCE_ATTRIBUTES) are read once at process launch and frozen for the session's lifetime — no documented way to change them mid-session.
  • Per-event attributes that do vary (prompt.id, event.sequence, model, query_source, etc.) are all auto-computed by Claude Code itself — there is no hook, slash command, or setting that lets a user inject an arbitrary custom attribute into an individual event.
  • Anthropic's own documented workaround for attaching external IDs (like an issue/ticket) is to correlate telemetry with an external system in the receiving backend, by session_id and timestamp, after the fact — not by mutating the OTel payload itself.

9. Is claude_code.* actually a shared standard?​

No — it's a Claude-Code-proprietary namespace riding on top of a genuinely vendor-neutral transport. Two different layers are easy to conflate:

  • OTel the protocol (OTLP wire format, the log/metric/span data model, resource attributes) is industry-neutral. This project's receiver relies on exactly that generality — its AnyValue/KeyValue/LogRecord types in apps/api/src/otel.ts are pure generic-OTLP shapes, nothing Claude-specific.
  • OpenTelemetry GenAI Semantic Conventions (gen_ai.* namespace) is a further, more specific layer: an actual attempt at a cross-vendor schema for LLM telemetry, maintained by OTel's GenAI SIG so different providers' instrumentation can look alike — e.g. gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens.

The claude_code.api_request log event this project actually parses does not use those standardized names:

Conceptgen_ai.* standard nameclaude_code.api_request attribute
Modelgen_ai.request.model / gen_ai.response.modelmodel
Input tokensgen_ai.usage.input_tokensinput_tokens
Output tokensgen_ai.usage.output_tokensoutput_tokens
Cache-read tokensgen_ai.usage.cache_read.input_tokenscache_read_tokens
Cache-write tokensgen_ai.usage.cache_creation.input_tokenscache_creation_tokens

Some of those concepts aren't cross-vendor to begin with — cache_read_tokens/cache_creation_tokens reflect Anthropic's own prompt-caching mechanism; a provider without an equivalent feature wouldn't emit a semantically identical field regardless of naming.

Claude Code does emit the standardized gen_ai.* names — but only on two trace spans (claude_code.llm_request, claude_code.tool), a different OTel signal type from the logs/metrics this project consumes. Ironically, this project's receiver explicitly discards traces (apps/api/src/app.ts, POST /v1/traces: accepted and thrown away, purely so Claude Code's exporter doesn't log errors) — so the one part of Claude Code's telemetry that follows the shared cross-vendor schema is exactly the part not being kept; the part actually stored is the proprietary one.

Implication: this is why Gemini CLI and Codex CLI each have their own normalizer in apps/api/src/otel.ts instead of sharing Claude Code's mapping. A tool that emitted only gen_ai.*-conformant spans would additionally require consuming traces rather than logs.

Sources: Claude Code: Monitoring usage · Claude Code: Hooks reference · OpenTelemetry: Gen AI attribute registry · OpenTelemetry: Inside the LLM Call — GenAI Observability with OpenTelemetry