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 | |
|---|---|---|
| Nature | Live push, only captured while a receiver is listening | Written to disk by Claude Code regardless of telemetry settings |
| Location | Streamed over OTLP/HTTP | ~/.claude/projects/<sanitized-cwd>/<session_id>.jsonl |
| Format | Generic 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 |
| Content | Attributes only (model, token counts, dimensions) | Full conversation content — prompts, thinking blocks, tool calls & results, plus the same usage numbers |
2. Level of detail
| OTel logs | OTel metrics | Transcripts | |
|---|---|---|---|
| Granularity | 1 event per real model API call | periodic aggregate counters (~60s) | per API call (see cardinality below), plus every prompt/response/tool-call |
| Conversation content | no | no | yes |
| Token fields | input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens | n/a | same fields, plus thinking_tokens, 1h/5m cache split, service_tier, per-iteration breakdown |
| Productivity data | no | LOC added/removed, active time, commit/PR counts, Claude Code's own cost calc | a cost-state entry with running totals per session |
| Differentiates thinking/tool-use/text? | no | no | yes, 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_idis used by both data sources — it's the transcript's filename, and it's thesession.idattribute 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
entrypointfield ("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 samemessage.id/usage. - Important nuance: even the transcript doesn't subdivide cost below the API-call level.
usageis 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 distinctmessage.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—typeattribute:"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:
| Metric | Dimensions beyond session |
|---|---|
commit.count / pull_request.count | none |
active_time.total | type |
lines_of_code.count | model, type |
cost.usage | model, 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_idand 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/LogRecordtypes inapps/api/src/otel.tsare 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:
| Concept | gen_ai.* standard name | claude_code.api_request attribute |
|---|---|---|
| Model | gen_ai.request.model / gen_ai.response.model | model |
| Input tokens | gen_ai.usage.input_tokens | input_tokens |
| Output tokens | gen_ai.usage.output_tokens | output_tokens |
| Cache-read tokens | gen_ai.usage.cache_read.input_tokens | cache_read_tokens |
| Cache-write tokens | gen_ai.usage.cache_creation.input_tokens | cache_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