Skip to content

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

DestinationContentsConstraint
DimMetricbounded enumsexplicit allowlist, dimension_cap, overflow to other
AttrRecordidentifiers, paths, commands, free stringslog and span attributes only
Contentprompts, messages, tool input and output, reasoning summarieslength-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.

ConceptLive evidenceOwner and service boundary
Cost in USDcount(count_over_time(claude_code_cost_usage_USD_total[30d])) = 3464claude_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 totalscount(count_over_time(claude_code_token_usage_tokens_total[30d])) = 13856; native type labels include input, output, cacheRead, and cacheCreationclaude_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 codecount(count_over_time(claude_code_lines_of_code_count_total[30d])) = 808claude_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 durationLoki: sum(count_over_time({service_name="claude-code"} \|= "claude_code.tool_result" [30d])) = 120297Claude owns its native log event. The service owns the latency histogram native lacks and Codex exit codes.
Session countcount(count_over_time(claude_code_session_count_total[30d])) = 1385claude_code.session.count is authoritative for live Claude. codingagent.sessions emits Codex sessions and explicitly ranged historical backfill only.
Commits and PRscount(count_over_time(claude_code_commit_count_total[30d])) = 349; count(count_over_time(claude_code_pull_request_count_total[30d])) = 4Claude owns both native series; this service emits neither.
Active time and edit decisionscount(count_over_time(claude_code_active_time_seconds_total[30d])) = 1671; count(count_over_time(claude_code_code_edit_tool_decision_total[30d])) = 1077Claude owns both native series; this service emits neither.
Prompt and response textLoki: 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])) = 44439Claude owns its native log events. The service records retain the Codex and historical transcript surfaces.
Codex native event streamLoki: 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])) = 0Codex'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

InstrumentShapeDimensions
codingagent.turnscounter {turn}tool, namespace, model, effort, outcome, thread_source
codingagent.responsescounter {response}tool, namespace, model, effort, stop_reason
codingagent.sessionscounter {session}tool, namespace, entrypoint, thread_source
codingagent.turn.durationhistogram stool, namespace, model, outcome, duration_source
codingagent.ttfthistogram stool, namespace, model, effort - Codex only, duration_source=native
codingagent.response.durationhistogram stool, 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

InstrumentShapeDimensions
gen_ai.client.token.usagehistogram {token}gen_ai.provider.name, gen_ai.operation.name, gen_ai.token.type, model
codingagent.tokenscounter {token}tool, namespace, model, token_type, cache_ttl, effort, backfill
codingagent.cost_usdgauge USDtool, namespace, model, backfill - Claude backfill only
codingagent.context.window_usedgauge 1tool, 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

InstrumentShapeDimensions
codingagent.tool.callscounter {call}tool, namespace, tool_name, outcome, exit_class
codingagent.tool.durationhistogram stool, namespace, tool_name, outcome, duration_source
codingagent.itemscounter {item}tool, namespace, item_kind
codingagent.item.durationhistogram stool, 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

InstrumentShapeDimensions
codingagent.subagent.spawnscounter {spawn}tool, namespace, depth_bucket
codingagent.subagent.depthhistogram 1tool, namespace
codingagent.agent.messagescounter {message}tool, namespace, direction

Agent names and paths are unbounded and are AttrRecord fields, not dimensions.

Compaction and limits

InstrumentShapeDimensions
codingagent.compactionscounter {compaction}tool, namespace, trigger
codingagent.compaction.durationhistogram stool, namespace, trigger
codingagent.compaction.tokens_droppedcounter {token}tool, namespace, trigger
codingagent.rate_limit.used_percentgauge 1namespace, limit_window - Codex only
codingagent.rate_limit.reachedcounter {event}namespace, limit_window
codingagent.errorscounter {error}tool, namespace, error_kind, status_class

Code churn

InstrumentShapeDimensions
codingagent.code.lines_changedcounter {line}tool, namespace, change_type, repo
codingagent.code.files_changedcounter {file}tool, namespace, change_type, repo

repo comes from the repo_allowlist mapping of cwd. cwd itself never appears.

Self-observability

InstrumentShapeMeaning
codingagent.selfobs.ingest_laggauge snewest record timestamp behind wall clock
codingagent.selfobs.checkpoint_agegauge ssince last durable checkpoint flush
codingagent.selfobs.files_trackedgauge {file}files under active poll
codingagent.selfobs.bytes_readcounter Byby namespace
codingagent.selfobs.decode_errorscounter {error}by tool and reason
codingagent.selfobs.unhandled_record_typescounter {record}by tool and record type - the drift alarm
codingagent.selfobs.attribute_rejectionscounter {attribute}a dimension value hit the cap
codingagent.selfobs.sink_rejectionscounter {record}by sink and reason
codingagent.selfobs.dedup_dropscounter {record}duplicate item suppressed
codingagent.selfobs.enrich_lookupscounter {lookup}cache_hit, hit, miss, error, disabled
codingagent.drift_findingsgauge {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.

TypeBodyNotes
sessionsession open/close with full contextone per session
turnturn outcome, timings, usage, no bodiesthe event timeline
promptuser prompt textcontent
messageassistant message textcontent
reasoning_summaryCodex summary_text onlyencrypted reasoning is never decodable
tool_calltool name, argumentscontent
tool_resultstdout, stderr, exit code, durationcontent
file_changepath, change type, line countscontent when the patch is included
compactiontrigger, before/after tokens, duration
subagentspawn, message, completion
web_searchquery and result domains
mcp_callserver, tool, duration, resultcontent
errorapi error, tool error, decode erroralways 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.