Your team's Claude Code spend lands as one number on an invoice. Then someone asks the obvious follow-ups. Which team drove last month's jump? Was it the expensive model or the cheap one? Are subagents quietly burning half the budget? Nobody can answer, because the data that would answer it was never switched on.
Claude Code ships an OpenTelemetry exporter. Export is off until you set one environment variable, CLAUDE_CODE_ENABLE_TELEMETRY=1, per the monitoring docs; a few more say where the data goes. Once it is on, every API request bumps a cost counter and a token counter, each tagged with the model, the user, the session and the part of Claude Code that made the call.
This walkthrough runs the whole pipeline on your laptop: Claude Code into an OpenTelemetry Collector, scraped by Prometheus, drawn by Grafana, all in one Docker Compose file. Then you build one panel per question from the opening.
Two signals matter for a spend dashboard. Metrics go out through the metrics exporter; events go out through the logs exporter. Per the docs, the metrics are:
claude_code.cost.usage (USD), incremented after each API request, with model, query_source (main, subagent or auxiliary), effort, and attribution for the active skill, plugin or subagent. claude_code.token.usage (tokens), split by type: input, output, cacheRead, cacheCreation. claude_code.session.count, claude_code.lines_of_code.count, claude_code.commit.count, claude_code.pull_request.count, claude_code.active_time.total. claude_code.code_edit_tool.decision, which counts accept and reject decisions on Edit, Write and NotebookEdit, with the file's language.
The events include user_prompt, api_request (with cost_usd, duration_ms and token counts per call), api_error, tool_result and tool_decision.
Every metric and event carries session.id and user.id, a random installation identifier. When you sign in with a Claude account, you also get organization.id, user.account_uuid and user.email; the docs say the email goes only to the endpoint you configure, never to Anthropic.
The defaults lean private. Prompt text is not collected, only its length, unless you set OTEL_LOG_USER_PROMPTS=1. Bash commands and MCP tool arguments stay out unless you set OTEL_LOG_TOOL_DETAILS=1. The docs also state that raw file contents and code snippets are not part of metrics or events. Leave all of those flags off for a cost dashboard. You don't need them.
Make a directory and put four files in it. The Collector receives OTLP from Claude Code and re-exposes metrics in Prometheus format on port 8889. Prometheus scrapes that. Grafana reads Prometheus.
Claude Code pushes OTLP to the Collector; Prometheus scrapes it; Grafana draws it.
docker-compose.yml, with image tags pinned to the current releases of the Collector, Prometheus and Grafana:
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.162.0
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "8889:8889" # Prometheus scrape endpoint
prometheus:
image: prom/prometheus:v3.15.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana:13.2.4
environment:
GF_SECURITY_ADMIN_PASSWORD: change-me
volumes:
- ./grafana:/etc/grafana/provisioning/datasources:ro
ports:
- "3000:3000"
otel-collector.yaml. Metrics go to the Prometheus exporter. Events go to the debug exporter so you can watch them in the container logs without running a log store yet:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
prometheus:
endpoint: 0.0.0.0:8889
debug:
verbosity: detailed
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug]
prometheus.yml:
global:
scrape_interval: 15s
scrape_configs:
- job_name: otel-collector
static_configs:
- targets: ["otel-collector:8889"]
grafana/prometheus.yaml, a provisioned data source so you skip the click-through:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
Then docker compose up -d. Grafana is on port 3000 (user admin, the password from the Compose file).
In the shell where you start Claude Code:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative
export OTEL_METRIC_EXPORT_INTERVAL=10000
export OTEL_RESOURCE_ATTRIBUTES="team.id=platform"
claude
Three of those lines deserve a word.
The temporality line. Claude Code's default is delta, and the docs say to switch to cumulative if your backend expects it. Prometheus counters are cumulative, and the Collector's Prometheus exporter README doesn't document what it does with delta sums, so asking for cumulative at the source takes the question off the table.
The export interval. The default is 60,000 ms. The docs suggest a shorter interval while you debug; 10 seconds means your first numbers show up fast. Put it back to the default once things work.
The resource attribute. OTEL_RESOURCE_ATTRIBUTES keys land on every metric data point as labels by default, so team.id=platform gives you a team dimension for free. Values can't contain spaces.
Run a prompt or two, then check what the Collector exposes:
curl -s localhost:8889/metrics | grep '^claude_code'
Read the metric names off that output rather than guessing. The exporter swaps dots for underscores and appends the unit and _total to counters, so the cost counter shows up as claude_code_cost_usage_USD_total and tokens as claude_code_token_usage_tokens_total. Those are the exact names the ROI measurement guide in Anthropic's GitHub organization queries. Labels follow the same rule: user.email becomes user_email, team.id becomes team_id. If nothing shows up, the docs recommend claude --debug-file <path> and a search for [3P telemetry] errors.
To roll this out to a team instead of one laptop, put the same variables in the env block of Claude Code's managed settings file. The docs note that a repository's .claude/settings.json cannot turn telemetry on or redirect it, so a stray repo config can't siphon your data somewhere else.
Create a dashboard. For each panel, pick the Bar gauge visualization, set the query type to Instant, and use Grafana's $__range variable, which equals the dashboard's time window. Change the time picker to 30 days and every panel follows.
Who spends. Estimated USD per person over the selected window:
sum by (user_email) (
increase(claude_code_cost_usage_USD_total[$__range])
)
If your developers authenticate with API keys instead of OAuth, there is no email to group by (more on that below). Swap user_email for team_id and the panel becomes spend per team.
Which model. Same counter, different label:
sum by (model) (
increase(claude_code_cost_usage_USD_total[$__range])
)
This is the panel that starts conversations. When one model dominates, the next question is whether the work being done needs it.
What kind of work. query_source splits the main conversation from subagents and auxiliary requests:
sum by (query_source) (
increase(claude_code_cost_usage_USD_total[$__range])
)
For a finer cut, group by agent_name or skill_name instead. The docs redact user-defined agent names to custom and third-party plugin names to third-party unless OTEL_LOG_TOOL_DETAILS=1 is set, so expect those buckets. On the cost and token counters the flag needs Claude Code v2.1.273 or later; earlier versions stay redacted even with it set.
One more query worth a Stat panel: the share of input-side tokens served from the prompt cache. The docs point out that the input type excludes cache reads and writes, so the denominator has to add all three:
sum(rate(
claude_code_token_usage_tokens_total{type="cacheRead"}[1h]
))
/
sum(rate(
claude_code_token_usage_tokens_total{
type=~"input|cacheRead|cacheCreation"
}[1h]
))
If that ratio falls off a cliff, something changed what gets cached, and the token panel tells you when it started.
It's an estimate. The docs say it directly: cost metrics are approximations, and official billing data comes from your API provider (the Claude Console, Amazon Bedrock or Google Cloud). If you're on a seat-based plan, treat the USD figure as relative consumption, not as the invoice.
Upgrade before you trust a gateway setup. Before Claude Code v2.1.214, streams that reported usage across multiple frames through a proxy behind ANTHROPIC_BASE_URL inflated the cost and token counters by roughly one extra request per extra frame, per the docs.
API keys and cloud providers carry no identity. With a direct API key, Bedrock, Google Cloud or Microsoft Foundry, only user.id (a random installation identifier) and session.id are populated. Attach identity yourself through OTEL_RESOURCE_ATTRIBUTES, set per user in managed settings or a launch wrapper.
Session IDs are a cardinality cost. session.id is on every series by default, which helps drill-downs and bloats long-term storage. OTEL_METRICS_INCLUDE_SESSION_ID=false drops it.
Short sessions can under-count. Each session starts new series, and increase() works from the samples Prometheus actually scraped, so increments made before a series' first scrape can go missing (explained on the prometheus-users list). A short export interval narrows the gap. Prometheus also has a created-timestamp-zero-ingestion feature flag that may help here.
Desktop sessions use a different service name. Local sessions started from the Code tab in the Claude Desktop app report service.name as claude-code-desktop, not claude-code. Add both if you filter on it.
The panels count spend, tokens and accepted edits. They don't tell you whether the work was any good. An accepted edit is a decision="accept" data point whether the change survives review or gets reverted the next morning. Lines added says nothing about lines that should have been deleted. With the privacy defaults on, you also can't see what was asked. That is the right default, and it is also why "why was this session expensive" has no answer in the metrics.
Telemetry makes the receipt sharp. The quality of the work stays out of frame.
Claude Code has beta tracing behind CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1, with claude_code.interaction spans that contain LLM request and tool spans. That gives you structure and latency. It still doesn't give you a verdict on output quality.
All of this measures the tool your developers use. Your product's LLM calls are a separate system. If you ship LLM features to customers, those calls need their own tracing, usually on the OpenTelemetry GenAI semantic conventions, and their own evals. Watch one mismatch if you put both on one dashboard: the docs note Claude Code doesn't set gen_ai.usage.* attributes, and its input_tokens excludes cache tokens, while the conventions count them. Add input, cache read and cache creation to compare like with like.
The Claude Code dashboard is the easy half, because the tool already emits the data. The harder half is the LLM calls inside your own product, where you choose the tracing backend, the eval approach and the trade-offs between them. That choice is what the LLM Observability Pocket Guide is about: picking tracing and eval tools that fit your team instead of the loudest vendor.