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-collector writes the raw export to JSONL, and a small polling sidecar turns that into a claude_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:

1
2
3
4
5
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

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

1
claude (OTLP) -> otel-collector -> data/claude-events.jsonl -> csv-writer -> claude_usage.csv

Pipeline: claude exports OTLP to otel-collector, which writes claude-events.jsonl, polled by csv-writer into claude_usage.csv

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  file:
    path: /var/log/otel/claude-events.jsonl

service:
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [file]
    metrics:
      receivers: [otlp]
      exporters: [file]
    traces:
      receivers: [otlp]
      exporters: [file]

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    restart: unless-stopped
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
      - ./data:/var/log/otel
    ports:
      - "127.0.0.1:4317:4317" # OTLP gRPC, localhost-only
      - "127.0.0.1:4318:4318" # OTLP HTTP

  csv-writer:
    image: python:3.12-slim
    restart: unless-stopped
    volumes:
      - .:/app
    working_dir: /app
    command: >
      sh -c "while true; do python3 parse_to_csv.py --input-file data/claude-events.jsonl --output-file claude_usage.csv; sleep 5; done"
    depends_on:
      - otel-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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
FIELDS = [
    "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",
]

TARGET_EVENTS = {"api_request", "tool_result", "tool_decision"}

def attr_value(v):
    for key in ("stringValue", "intValue", "doubleValue", "boolValue"):
        if key in v:
            return v[key]
    return ""

def record_to_row(record):
    attrs = {a["key"]: attr_value(a["value"]) for a in record.get("attributes", [])}
    event_type = attrs.get("event.name")
    if event_type not in TARGET_EVENTS:
        return None
    return {"timestamp": attrs.get("event.timestamp", ""), "session_id": attrs.get("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):

timestampsession_idevent_typemodeltool_namedecisionsourcesuccesserror_typecost_usdduration_msinput_tokensoutput_tokenscache_read_tokenscache_creation_tokenstool_use_id
2026-07-04T19:41:05.216Z4255b08c…tool_decisionReadacceptconfigtoolu_01LV…
2026-07-04T19:41:05.218Z4255b08c…api_requestclaude-sonnet-50.076066295821013034210907
2026-07-04T19:41:05.221Z4255b08c…tool_resultReadtrue5toolu_01LV…
2026-07-04T19:41:46.045Z4a4f71f6…tool_decisionGrepacceptconfigtoolu_018W…
2026-07-04T19:41:46.052Z4a4f71f6…api_requestclaude-opus-4-80.2632844159702242020872

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:

Bar chart: cost by model (claude-sonnet-5 $0.3368, claude-haiku-4-5 $0.0623, claude-opus-4-8 $0.4095) and tool calls by tool (Read 2, Grep 1, Glob 1)

Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
git clone https://github.com/sudopower/claude-otel-capture.git
cd claude-otel-capture

# 1. Start the collector + csv-writer, leave this running
docker compose up -d

# 2. In the terminal where you'll run Claude Code:
source env.sh
claude
# ... use Claude Code normally ...

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:

Terminal: docker compose ps showing both containers up, then real claude_usage.csv rows for claude-sonnet-5, claude-haiku-4-5, and claude-opus-4-8, plus Read/Grep/Glob tool decisions

Gotchas

  • The file exporter holds an open handle. Deleting data/claude-events.jsonl from 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’t rm the 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.email and 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 *.csv are 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.