TL;DR: Claude Code exports OTLP logs, metrics, and traces if a handful of env vars are set before a session starts. A local
otel-collectorwrites the raw export to JSONL, and a small polling sidecar turns that into aclaude_usage.csv(model, cost, tokens, tool calls) with nothing to trigger by hand. Repo here.
The Goal
I wanted a running log of what my Claude Code sessions actually cost: tokens per model, dollars, which tools got called and how often they succeeded. Claude Code doesn’t have a built-in dashboard for this, but it does speak OpenTelemetry, so the plan was to catch that export locally and turn it into something I could open in a spreadsheet.
Telemetry Has to Be on Before You Start
The first thing I tried was turning on export mid-session. That doesn’t
work: Claude Code reads its telemetry configuration once, at startup. There
is no way to retroactively enable export for a session that’s already
running, and no built-in buffer-and-replay-later feature either. If you want
a session’s traces, the env vars have to be set in the shell before
claude launches:
The second thing that has to be true: something has to actually be listening on that endpoint when Claude Code starts. If nothing’s there, the exporter just fails quietly and you get nothing. So the env vars alone aren’t the setup, they’re the last step.
Architecture
| |
Two containers. otel-collector receives OTLP over gRPC/HTTP and writes the
raw export straight to a JSONL file, nothing parsed or dropped. csv-writer
polls that file every five seconds and regenerates the CSV, so there’s no
command to remember after the fact, the file just stays current while the
stack is up.
The Collector Config
otelcol-contrib needs a receiver and an exporter; the file exporter is
enough for this, no backend required yet:
| |
The docker-compose.yml binds the OTLP ports to 127.0.0.1 on purpose.
This receiver has no auth and no TLS, so exposing it on 0.0.0.0 means
anyone on the LAN can inject fake telemetry into your collector:
| |
Turning JSONL into a CSV
The raw export is nested OTLP: resourceLogs -> scopeLogs -> logRecords,
each with an attributes array instead of a flat object. The script filters
to the three event types worth graphing, api_request, tool_result, and
tool_decision, and flattens their attributes into one row per event, keyed
by session_id:
| |
api_request rows carry model, cost, and token counts; tool_result and
tool_decision carry which tool ran, whether it succeeded, and whether it
was auto-accepted or needed a prompt. Group by session_id and you have a
per-session cost and tool-usage breakdown. A few rows from a real
claude_usage.csv (all 16 columns, scroll right on narrow screens):
| timestamp | session_id | event_type | model | tool_name | decision | source | success | error_type | cost_usd | duration_ms | input_tokens | output_tokens | cache_read_tokens | cache_creation_tokens | tool_use_id |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-07-04T19:41:05.216Z | 4255b08c… | tool_decision | Read | accept | config | toolu_01LV… | |||||||||
| 2026-07-04T19:41:05.218Z | 4255b08c… | api_request | claude-sonnet-5 | 0.076066 | 2958 | 2 | 101 | 30342 | 10907 | ||||||
| 2026-07-04T19:41:05.221Z | 4255b08c… | tool_result | Read | true | 5 | toolu_01LV… | |||||||||
| 2026-07-04T19:41:46.045Z | 4a4f71f6… | tool_decision | Grep | accept | config | toolu_018W… | |||||||||
| 2026-07-04T19:41:46.052Z | 4a4f71f6… | api_request | claude-opus-4-8 | 0.26328 | 4415 | 9702 | 242 | 0 | 20872 |
These are real rows, one claude -p call each, on Sonnet and Opus, one reading a
file and one grepping one. Grouped by model and by tool, the same session looks
like this:
Setup
claude_usage.csv updates itself every five seconds while the stack is up,
nothing else to run. Here’s what that actually looks like, both containers up
and a couple of real rows pulled straight out of the CSV:
Gotchas
- The file exporter holds an open handle. Deleting
data/claude-events.jsonlfrom the host while the collector is running leaves it writing into a deleted, invisible inode until the container restarts. If you need to reset it,docker compose restart otel-collector, don’trmthe file while it’s live. This one cost me an hour of “why is my CSV empty” before I noticed the container had never stopped writing, just to nowhere I could see. user.emailand account UUIDs are in the raw export. Prompt and response text are redacted by default, but the JSONL still isn’t something to commit.data/and*.csvare gitignored in the repo.
Phase 2
data/claude-events.jsonl is the full, untouched OTLP export, so nothing
needs to be replayed from Claude Code itself to backfill a different
backend later. A second collector with a filelog/otlpjsonfile receiver
pointed at that file and a clickhouse exporter would ingest the whole
history in one pass. Not wired up yet, but the reason the raw file is kept
around instead of just the derived CSV.