Alpha TELEMETRY SETUP
TELEMETRY INGESTION

Nothing works until rows arrive.

TokenPlane does not talk to any vendor API and has no agent of its own. It reads one ClickHouse database that an OpenTelemetry collector writes to. This page is what the dashboard's first-run banner points at: what the pipeline expects, how to make each supported client emit it, and how to tell which stage is stuck when it doesn't.

01 — THE CONTRACT

Anything that speaks OTLP can feed it.

your client
     │   OTLP/HTTP :4318  (http/protobuf)
     ▼
otel-collector
     │   native :9000
     ▼
ClickHouse · otel
     ├─ raw    otel_logs / otel_metrics_* / otel_traces
     └─ typed  otel_events_api_request   ← every dollar
Your client is the left-hand box. Everything to its right is already running — Getting started covers standing it up.
Endpointhttp://localhost:4318 (OTLP/HTTP). gRPC on :4317 also works
Protocolhttp/protobuf
Signalslogs and metrics at minimum. Traces are used only by the gateway path below
Databaseotel, created by the collector (create_schema: true) plus queries/apply_schema.py for the typed tables and views

The one table the dashboard cannot do without is otel_events_api_request — one row per API round-trip, and the denominator of every cost figure on every page. It is populated by a materialized view over the raw log stream, so a client that exports metrics but no logs will show session counts and little else.

Both signals matter and they behave differently: metrics are cheap counters that flush on a timer, logs are the per-event detail. The setup below turns on both.
02 — CLAUDE CODE

Native OpenTelemetry, no proxy.

Claude Code emits OpenTelemetry itself. Copy the env block from claude-code/settings.json into your ~/.claude/settings.json. The load-bearing variables:

VariableWhat it does
CLAUDE_CODE_ENABLE_TELEMETRY=1master switch
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
export both signals
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
point at the collector
OTEL_LOG_USER_PROMPTS=1
OTEL_LOG_TOOL_DETAILS=1
enrich log events with content
OTEL_LOG_RAW_API_BODIES=file:<repo>/data/raw-api-bodieswrite full request/response bodies to disk — this powers the Session Metrics context-window breakdown (inline mode truncates at 60 KB and the JSON doesn't even parse)
OTEL_METRICS_INCLUDE_SESSION_ID=true (and the other OTEL_METRICS_INCLUDE_*)put session.id on metrics rows so they join to sessions
This is the full-capture configuration, appropriate for measuring your own usage on your own machine. OTEL_LOG_USER_PROMPTS records your prompt text into ClickHouse, and OTEL_LOG_RAW_API_BODIES writes complete request/response JSON (prompts, completions, file contents in context) to unencrypted files on disk. Every row also carries your account email. If the store will hold anyone else's sessions, use the partner defaults in deploy/SETUP.md instead — both content flags off — and get an explicit OK before enabling them.

Gotchas — read these before concluding capture is broken

03 — CODEX CLI

Native OpenTelemetry too — three pieces, in order.

Codex CLI emits OpenTelemetry itself, under a ChatGPT-plan sign-in — no API key and no gateway. It needs three pieces of client-side setup where Claude Code needs one, and the failure modes are quieter, so do them in order and confirm after each.

1. The [otel] block

Merge codex/config.toml into your ~/.codex/config.toml — merge, don't overwrite; the file also holds your project trust levels. The load-bearing lines:

SettingWhat it does
[otel.exporter.otlp-http]
endpoint = "http://localhost:4318/v1/logs"

(and /v1/traces, /v1/metrics for the other two exporters)
point each signal at the collector — the full signal URL, one per exporter. Codex does not append the path: given a base URL it POSTs to /, the collector answers 404, and the CLI says nothing while the completion succeeds. The opposite of Claude Code's single base-URL variable
protocol = "binary"http/protobuf
log_user_prompt = trueput the prompt on the codex.user_prompt log row. Required for explicit skill activations: a $skill mention exists nowhere else, so the collector reads the mention from the prompt and then deletes the prompt before it reaches ClickHouse. The text transits the local collector and is never stored
environment = "…"a fleet label on every row; not a session key

Traces are not optional here: the Codex cost row is built from the turn span, so a config that exports logs and metrics but not traces produces sessions with identity and no spend.

2. The ticket hook

Copy codex/hooks.json to ~/.codex/hooks.json and fill in the absolute path to codex/hooks/ticket-capture.py. It is the same <TICKET-KEY>-… branch convention as Claude Code and writes the same ticket rows. Then establish hook trust — Codex will not run a hook it has not been told to trust, and it does not warn when it skips one:

Measured before this was documented: the hook fired on 31% of Codex sessions against 100% on Claude Code, entirely from unattended runs. Diagnostics → Capture health shows the ratio per client so you can see whether it is firing.

3. Project trust

~/.codex/config.toml needs [projects."/path/to/repo"] trust_level = "trusted" for the working tree you run in, or Codex runs read-only and the hook cannot read the branch.

What to expect once it works

A turn appears about a minute after it finishes, not in seconds: the cost row is joined from four spans by a refreshable view on a 60 s timer. The per-session websocket warm-up request is real traffic that turn accounting excludes; Session Metrics states it above the charts rather than hiding it. A sub-agent Codex spawns is its own thread and shows as agent spend under the parent session and the turn that spawned it — it is not a separate session — and it waits for the parent turn to close before it files. A $skill mention shows on the Skills tab once Codex's own skill-injected counter confirms it ran (on Codex 0.149.0 that counter covers host-catalogue skills only; plugin-skill mentions appear from 0.151.0). A skill the model read on its own shows as a proactive activation, excluded from the tab's user-invoked figures like Claude Code's.

Privacy. The collector deletes tool arguments and output and the prompt before ClickHouse, keeping only the keys it extracted (a skill name, a child thread id, a mention). Set COLLECTOR_RETAIN_CODEX_TOOL_CONTENT=1 on the collector to keep all three — not on a store that holds anyone else's sessions.

Gotchas — Codex

04 — OTHER PROVIDERS

Everything else goes through the gateway.

Clients that don't emit OpenTelemetry themselves are routed through the LiteLLM gateway (compose service litellm), whose OTel callback exports spans to the same collector. docs/capture-coverage.md carries the per-route maturity table and exactly which fields survive each hop.

client
     │
LiteLLM gateway
     │   OTLP  (OTel callback)
     ▼
otel-collector  ──▶  otel_traces
                          │   queries/ingest_litellm.py
                          ▼   batch — you run it
        otel_logs + otel_events_api_request

This path is proven, not turnkey, and the honest limits are:

05 — CONFIRMING

Did rows actually land?

Fastest first:

# 1. Is the collector listening? The OTLP receiver is POST-only on /v1/*, so a
#    GET to a real OTLP path answers 405 — any HTTP status proves something is
#    there. Only "000" means nothing answered. (Don't test / with curl -f: the
#    receiver 404s that path even when healthy, so -f reports a false failure.)
$ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4318/v1/logs)
$ [ "$code" = "000" ] && echo "collector NOT listening" || echo "listening (HTTP $code)"

# 2. Has anything been written at all?
$ curl -s "http://localhost:8123/?user=otel&password=$CH_PASSWORD" \
    --data "SELECT count() FROM otel.otel_logs"

# 3. The one that matters — the table every dollar comes from:
$ curl -s "http://localhost:8123/?user=otel&password=$CH_PASSWORD" \
    --data "SELECT count() FROM otel.otel_events_api_request"

# 4. Codex specifically — spans arrived, and the refresher turned them into cost
#    rows (allow a minute; the second count lags the first by one refresh):
$ curl -s "http://localhost:8123/?user=otel&password=$CH_PASSWORD" \
    --data "SELECT countIf(SpanName = 'session_task.turn') FROM otel.otel_traces WHERE ServiceName = 'codex_exec'"
$ curl -s "http://localhost:8123/?user=otel&password=$CH_PASSWORD" \
    --data "SELECT count() FROM otel.otel_events_api_request WHERE query_source LIKE 'codex%' OR query_source = 'agent:builtin:codex-subagent'"

In the dashboard, Diagnostics answers the same question with no shell: the liveness panel shows the age of the newest part per table, so a stalled collector reads as a growing age rather than an absence you have to interpret.

Expect a lag of roughly 20 seconds from a Claude Code CLI turn to a queryable row — client export interval plus the collector's 5 s batch window — and up to a minute more for a Codex turn's cost row. Nothing is wrong until that has passed. The Diagnostics Codex promotion panel shows both refreshers and their last success.
06 — WHEN NOTHING ARRIVES

One command, one file, one FAIL line.

Run the bundled diagnostic from the install folder. It walks every stage — engine, collector, ClickHouse, dashboard, client settings — and prints one OK/FAIL line each, so the break point is the first FAIL rather than a guess:

$ ./diagnose.sh            # writes ./tokenplane-diag.txt

On Windows, run it from the WSL2 shell the same way, or use the native PowerShell version: powershell -ExecutionPolicy Bypass -File .\diagnose.ps1. The report is redacted by default — no passwords, tokens, or telemetry content, just status and counts — so it is safe to send on. (--full adds operational log and error detail, still with secrets and prompt content stripped.)

SymptomUsual cause
receiving / stored WARN, everything else OKNothing has been sent yet. Check the client is actually configured and was restarted after the change
otel_logs at 0 mid-session, metrics flowingNormal for a Claude Code desktop session — event-logs flush on clean exit
Rows in otel_logs but otel_events_api_request emptyThe typed tables or their materialized views are missing. Run python3 queries/apply_schema.py
Gateway traffic in otel_traces onlyExpected — run queries/ingest_litellm.py (§4); the mapper is a batch step
Codex session in otel_logs, nothing in otel_tracesThe trace exporter is missing or points at a base URL. Every Codex exporter needs its full signal URL (§3)
Codex spans in otel_traces, no cost rows after a minuteThe refreshable promotion is stopped or absent. Diagnostics → Codex promotion; then python3 queries/apply_schema.py, and --promote-codex to replay a gap
Codex spend all under (untracked) on OutcomesThe ticket hook is not trusted — the “Hooks need review” prompt was answered with Review or Continue without trusting, or nobody has run interactive codex on that machine yet (§3). Capture health shows the per-client ratio
A $skill mention never reaches the Skills tablog_user_prompt is false, or the skill is served by a plugin on Codex < 0.151 (§3). Capture health's Codex tripwire compares the client's counter to the promoted rows
Collector reachable, ClickHouse refusingCredential mismatch. CH_USER / CH_PASSWORD must agree between .env, the collector, and the backend