---
title: Signals
description: Metrics, log records and spans emitted by codingagent2otel, and the native-OTel overlap
---

# 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`.
