Signals¶
All three signals derive from the same unified model. internal/attr owns every name below and is the single source of truth; the dashboard generator validates its queries against it.
OTLP-to-Prometheus translation replaces dots with underscores and adds counter and unit suffixes. Query the translated names, not the dotted ones.
Where a field is allowed to go¶
| Destination | Contents | Constraint |
|---|---|---|
DimMetric | bounded enums | explicit allowlist, dimension_cap, overflow to other |
AttrRecord | identifiers, paths, commands, free strings | log and span attributes only |
Content | prompts, messages, tool input and output, reasoning summaries | length-bounded bodies |
A field's destination is a property of the field. Nothing from AttrRecord or Content may become a metric attribute, and that is the only cardinality rule.
Specifically never a metric attribute: session.id, turn.id, response.id, call.id, trace.id, cwd, file paths, command text, git branch, agent thread id, instructions hash.
Bounded and therefore allowed: codingagent.tool (claude-code | codex), namespace, gen_ai.request.model, effort, entrypoint, thread_source, permission_mode, sandbox_mode, approval_policy, tool.name, item.kind, outcome, stop_reason, exit_class, codingagent.repo (allowlisted, other overflow), codingagent.machine, codingagent.duration_source.
Metric namespace¶
Everything is under codingagent.. Never emit under claude_code. or any vendor prefix: those belong to the clients' own exporters and a collision makes both unreadable.
Native OTel overlap¶
Both CLIs already export OTLP to the same collector. Do not emit a second series with the same meaning under a different name. The table was checked against the m7kni Mimir and Loki tenants at 2026-09-22T12:05:13Z: its quoted Mimir results use a 30-day range because an instant-vector absence only means that a series is stale at that instant.
| Concept | Live evidence | Owner and service boundary |
|---|---|---|
| Cost in USD | count(count_over_time(claude_code_cost_usage_USD_total[30d])) = 3464 | claude_code.cost.usage is authoritative for live Claude. codingagent.cost_usd emits only with codingagent.backfill=true from Claude cost-state; Codex has no native cost. |
| Token totals | count(count_over_time(claude_code_token_usage_tokens_total[30d])) = 13856; native type labels include input, output, cacheRead, and cacheCreation | claude_code.token.usage is authoritative for those live Claude totals. Live codingagent.tokens retains only reasoning plus the 1h and 5m cache-write split; gen_ai.client.token.usage retains per-iteration decomposition. A backfill can emit the full transcript decomposition, marked codingagent.backfill=true; all Codex tokens remain service-owned. |
| Lines of code | count(count_over_time(claude_code_lines_of_code_count_total[30d])) = 808 | claude_code.lines_of_code.count is authoritative for Claude. The service emits codingagent.code.lines_changed and codingagent.code.files_changed for Codex FileChange only. |
| Tool result success and duration | Loki: sum(count_over_time({service_name="claude-code"} \|= "claude_code.tool_result" [30d])) = 120297 | Claude owns its native log event. The service owns the latency histogram native lacks and Codex exit codes. |
| Session count | count(count_over_time(claude_code_session_count_total[30d])) = 1385 | claude_code.session.count is authoritative for live Claude. codingagent.sessions emits Codex sessions and explicitly ranged historical backfill only. |
| Commits and PRs | count(count_over_time(claude_code_commit_count_total[30d])) = 349; count(count_over_time(claude_code_pull_request_count_total[30d])) = 4 | Claude owns both native series; this service emits neither. |
| Active time and edit decisions | count(count_over_time(claude_code_active_time_seconds_total[30d])) = 1671; count(count_over_time(claude_code_code_edit_tool_decision_total[30d])) = 1077 | Claude owns both native series; this service emits neither. |
| Prompt and response text | Loki: sum(count_over_time({service_name="claude-code"} \|= "claude_code.user_prompt" [30d])) = 4148; sum(count_over_time({service_name="claude-code"} \|= "claude_code.assistant_response" [30d])) = 44439 | Claude owns its native log events. The service records retain the Codex and historical transcript surfaces. |
| Codex native event stream | Loki: sum(count_over_time({service_name="codex_cli_rs"}[30d])) = 12734988; the literal probe sum(count_over_time({service_name="codex_cli_rs"} \|= "[otel]" [30d])) = 0 | Codex's native event stream was checked separately. The zero literal match is a finding about this query, not evidence that Codex cannot emit [otel]; the service does not emit a Codex vendor-prefixed series and retains only transcript-only metrics. |
Every metric point has the bounded codingagent.backfill dimension: false for the normal tail and true for a ranged historical run. It keeps a backfill's overlap window queryable instead of silently adding it to native live data. The bounded-run wiring must construct the metric sink with otlpmetric.Config{Backfill: true}.
Dashboard preference¶
Use claude_code_cost_usage_USD_total, claude_code_token_usage_tokens_total, and claude_code_lines_of_code_count_total for live Claude cost, token totals, and lines of code. Use the service metrics for Codex and transcript-only decomposition; select codingagent_backfill="true" when intentionally plotting historical backfill. Never add a native Claude series to the corresponding codingagent series without first separating the backfill range.
Metrics¶
Every dimensions cell below also includes codingagent.backfill; it is omitted where redundant.
Turns and responses¶
| Instrument | Shape | Dimensions |
|---|---|---|
codingagent.turns | counter {turn} | tool, namespace, model, effort, outcome, thread_source |
codingagent.responses | counter {response} | tool, namespace, model, effort, stop_reason |
codingagent.sessions | counter {session} | tool, namespace, entrypoint, thread_source |
codingagent.turn.duration | histogram s | tool, namespace, model, outcome, duration_source |
codingagent.ttft | histogram s | tool, namespace, model, effort - Codex only, duration_source=native |
codingagent.response.duration | histogram s | tool, namespace, model - Claude only, duration_source=derived |
codingagent.duration_source is mandatory on every duration instrument. Claude Code has no measured TTFT; a derived value must never be emitted into codingagent.ttft.
Tokens and cost¶
| Instrument | Shape | Dimensions |
|---|---|---|
gen_ai.client.token.usage | histogram {token} | gen_ai.provider.name, gen_ai.operation.name, gen_ai.token.type, model |
codingagent.tokens | counter {token} | tool, namespace, model, token_type, cache_ttl, effort, backfill |
codingagent.cost_usd | gauge USD | tool, namespace, model, backfill - Claude backfill only |
codingagent.context.window_used | gauge 1 | tool, namespace, model |
codingagent.cost_usd is a gauge because cost-state is cumulative for the session. Summing it across records multiplies one session's cost by however many times it was observed.
gen_ai.token.type values are input, output, reasoning, cache_read, cache_write. The last three are breakdowns of the first two and must not be added to their parents. Claude's ephemeral_1h / ephemeral_5m split rides as cache_ttl on codingagent.tokens only.
Tools and items¶
| Instrument | Shape | Dimensions |
|---|---|---|
codingagent.tool.calls | counter {call} | tool, namespace, tool_name, outcome, exit_class |
codingagent.tool.duration | histogram s | tool, namespace, tool_name, outcome, duration_source |
codingagent.items | counter {item} | tool, namespace, item_kind |
codingagent.item.duration | histogram s | tool, namespace, item_kind, duration_source |
exit_class is ok, error, signal, timeout, absent - never the raw exit code, which is unbounded. The raw code is an AttrRecord field.
Agents¶
| Instrument | Shape | Dimensions |
|---|---|---|
codingagent.subagent.spawns | counter {spawn} | tool, namespace, depth_bucket |
codingagent.subagent.depth | histogram 1 | tool, namespace |
codingagent.agent.messages | counter {message} | tool, namespace, direction |
Agent names and paths are unbounded and are AttrRecord fields, not dimensions.
Compaction and limits¶
| Instrument | Shape | Dimensions |
|---|---|---|
codingagent.compactions | counter {compaction} | tool, namespace, trigger |
codingagent.compaction.duration | histogram s | tool, namespace, trigger |
codingagent.compaction.tokens_dropped | counter {token} | tool, namespace, trigger |
codingagent.rate_limit.used_percent | gauge 1 | namespace, limit_window - Codex only |
codingagent.rate_limit.reached | counter {event} | namespace, limit_window |
codingagent.errors | counter {error} | tool, namespace, error_kind, status_class |
Code churn¶
| Instrument | Shape | Dimensions |
|---|---|---|
codingagent.code.lines_changed | counter {line} | tool, namespace, change_type, repo |
codingagent.code.files_changed | counter {file} | tool, namespace, change_type, repo |
repo comes from the repo_allowlist mapping of cwd. cwd itself never appears.
Self-observability¶
| Instrument | Shape | Meaning |
|---|---|---|
codingagent.selfobs.ingest_lag | gauge s | newest record timestamp behind wall clock |
codingagent.selfobs.checkpoint_age | gauge s | since last durable checkpoint flush |
codingagent.selfobs.files_tracked | gauge {file} | files under active poll |
codingagent.selfobs.bytes_read | counter By | by namespace |
codingagent.selfobs.decode_errors | counter {error} | by tool and reason |
codingagent.selfobs.unhandled_record_types | counter {record} | by tool and record type - the drift alarm |
codingagent.selfobs.attribute_rejections | counter {attribute} | a dimension value hit the cap |
codingagent.selfobs.sink_rejections | counter {record} | by sink and reason |
codingagent.selfobs.dedup_drops | counter {record} | duplicate item suppressed |
codingagent.selfobs.enrich_lookups | counter {lookup} | cache_hit, hit, miss, error, disabled |
codingagent.drift_findings | gauge {finding} | by severity breaking, new, info |
Log records¶
One record per content kind, gated by logs.record_types. Every record carries AttrRecord identifiers as OTLP log attributes, which land as structured metadata rather than indexed labels.
| Type | Body | Notes |
|---|---|---|
session | session open/close with full context | one per session |
turn | turn outcome, timings, usage, no bodies | the event timeline |
prompt | user prompt text | content |
message | assistant message text | content |
reasoning_summary | Codex summary_text only | encrypted reasoning is never decodable |
tool_call | tool name, arguments | content |
tool_result | stdout, stderr, exit code, duration | content |
file_change | path, change type, line counts | content when the patch is included |
compaction | trigger, before/after tokens, duration | |
subagent | spawn, message, completion | |
web_search | query and result domains | |
mcp_call | server, tool, duration, result | content |
error | api error, tool error, decode error | always on |
Listing only turn, session, error, compaction and subagent gives an event timeline with no bodies. Empty means all of them.
Timestamps: an input-side record (prompt, tool result, file change) takes the input timestamp; a model-side record (message, tool call) takes the output timestamp. Ties sort by ordinal.
Spans¶
session
└── turn (Codex: native trace_id; Claude: derived from session id)
├── response (model call)
├── tool_call (linked to tool_result by call id)
├── file_change
├── compaction
└── subagent turn (child thread, linked by agent thread id)
Every span carries codingagent.duration_source. Codex spans have real boundaries from item_completed. Claude spans are bounded by record write times and are approximate; do not put an SLO on one.
codingagent.trace_id_source is native or derived.