{"slug": "claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in", "title": "Claude Code Already Emits OpenTelemetry: Who Spends What, on Which Model, in Grafana", "summary": "A developer demonstrated a local Docker Compose pipeline that turns Claude Code's built-in OpenTelemetry exporter into per-model, per-user spend dashboards in Grafana. Claude Code emits cost, token, session and code-edit metrics plus API request events once CLAUDE_CODE_ENABLE_TELEMETRY=1 is set, with prompt text and tool details excluded by default. The setup routes OTLP data through an OpenTelemetry Collector, scraped by Prometheus and visualized in Grafana.", "body_md": "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.\n\n[Claude Code](https://code.claude.com/docs/en/overview) ships an [OpenTelemetry](https://opentelemetry.io/) exporter. Export is off until you set one environment variable, `CLAUDE_CODE_ENABLE_TELEMETRY=1`, per the [monitoring docs](https://code.claude.com/docs/en/monitoring-usage); 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.\n\nThis walkthrough runs the whole pipeline on your laptop: Claude Code into an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/), scraped by [Prometheus](https://prometheus.io/docs/introduction/overview/), drawn by [Grafana](https://grafana.com/docs/grafana/latest/), all in one [Docker Compose](https://docs.docker.com/compose/) file. Then you build one panel per question from the opening.\n\nTwo signals matter for a spend dashboard. Metrics go out through the metrics exporter; events go out through the logs exporter. Per the [docs](https://code.claude.com/docs/en/monitoring-usage), the metrics are:\n\n`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`.\nThe events include `user_prompt`, `api_request` (with `cost_usd`, `duration_ms` and token counts per call), `api_error`, `tool_result` and `tool_decision`.\n\nEvery 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.\n\nThe 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.\n\nMake 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.\n\n*Claude Code pushes OTLP to the Collector; Prometheus scrapes it; Grafana draws it.*\n\n`docker-compose.yml`, with image tags pinned to the current releases of the [Collector](https://github.com/open-telemetry/opentelemetry-collector-releases/releases), [Prometheus](https://github.com/prometheus/prometheus/releases) and [Grafana](https://github.com/grafana/grafana/releases):\n\n```\nservices:\n  otel-collector:\n    image: otel/opentelemetry-collector-contrib:0.162.0\n    command: [\"--config=/etc/otelcol/config.yaml\"]\n    volumes:\n      - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro\n    ports:\n      - \"4317:4317\"   # OTLP gRPC\n      - \"4318:4318\"   # OTLP HTTP\n      - \"8889:8889\"   # Prometheus scrape endpoint\n\n  prometheus:\n    image: prom/prometheus:v3.15.0\n    volumes:\n      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro\n    ports:\n      - \"9090:9090\"\n\n  grafana:\n    image: grafana/grafana:13.2.4\n    environment:\n      GF_SECURITY_ADMIN_PASSWORD: change-me\n    volumes:\n      - ./grafana:/etc/grafana/provisioning/datasources:ro\n    ports:\n      - \"3000:3000\"\n```\n\n`otel-collector.yaml`. Metrics go to the [Prometheus exporter](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/prometheusexporter/README.md). Events go to the `debug` exporter so you can watch them in the container logs without running a log store yet:\n\n```\nreceivers:\n  otlp:\n    protocols:\n      grpc:\n        endpoint: 0.0.0.0:4317\n      http:\n        endpoint: 0.0.0.0:4318\n\nprocessors:\n  batch: {}\n\nexporters:\n  prometheus:\n    endpoint: 0.0.0.0:8889\n  debug:\n    verbosity: detailed\n\nservice:\n  pipelines:\n    metrics:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [prometheus]\n    logs:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [debug]\n```\n\n`prometheus.yml`:\n\n```\nglobal:\n  scrape_interval: 15s\n\nscrape_configs:\n  - job_name: otel-collector\n    static_configs:\n      - targets: [\"otel-collector:8889\"]\n```\n\n`grafana/prometheus.yaml`, a [provisioned data source](https://grafana.com/docs/grafana/latest/administration/provisioning/) so you skip the click-through:\n\n```\napiVersion: 1\ndatasources:\n  - name: Prometheus\n    type: prometheus\n    access: proxy\n    url: http://prometheus:9090\n    isDefault: true\n```\n\nThen `docker compose up -d`. Grafana is on port 3000 (user `admin`, the password from the Compose file).\n\nIn the shell where you start Claude Code:\n\n```\nexport CLAUDE_CODE_ENABLE_TELEMETRY=1\nexport OTEL_METRICS_EXPORTER=otlp\nexport OTEL_LOGS_EXPORTER=otlp\nexport OTEL_EXPORTER_OTLP_PROTOCOL=grpc\nexport OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317\nexport OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative\nexport OTEL_METRIC_EXPORT_INTERVAL=10000\nexport OTEL_RESOURCE_ATTRIBUTES=\"team.id=platform\"\nclaude\n```\n\nThree of those lines deserve a word.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nRun a prompt or two, then check what the Collector exposes:\n\n```\ncurl -s localhost:8889/metrics | grep '^claude_code'\n```\n\nRead 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](https://github.com/anthropics/claude-code-monitoring-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.\n\nTo 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.\n\nCreate a dashboard. For each panel, pick the Bar gauge visualization, set the query type to Instant, and use Grafana's [`$__range`](https://grafana.com/docs/grafana/v13.2/visualizations/dashboards/variables/global-variables/) variable, which equals the dashboard's time window. Change the time picker to 30 days and every panel follows.\n\n**Who spends.** Estimated USD per person over the selected window:\n\n```\nsum by (user_email) (\n  increase(claude_code_cost_usage_USD_total[$__range])\n)\n```\n\nIf 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.\n\n**Which model.** Same counter, different label:\n\n```\nsum by (model) (\n  increase(claude_code_cost_usage_USD_total[$__range])\n)\n```\n\nThis is the panel that starts conversations. When one model dominates, the next question is whether the work being done needs it.\n\n**What kind of work.** `query_source` splits the main conversation from subagents and auxiliary requests:\n\n```\nsum by (query_source) (\n  increase(claude_code_cost_usage_USD_total[$__range])\n)\n```\n\nFor 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.\n\nOne 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:\n\n```\nsum(rate(\n  claude_code_token_usage_tokens_total{type=\"cacheRead\"}[1h]\n))\n/\nsum(rate(\n  claude_code_token_usage_tokens_total{\n    type=~\"input|cacheRead|cacheCreation\"\n  }[1h]\n))\n```\n\nIf that ratio falls off a cliff, something changed what gets cached, and the token panel tells you when it started.\n\n**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.\n\n**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](https://code.claude.com/docs/en/monitoring-usage).\n\n**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.\n\n**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.\n\n**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](https://groups.google.com/g/prometheus-users/c/RchsfJVEfkQ/m/IovYZejXAAAJ)). A short export interval narrows the gap. Prometheus also has a [`created-timestamp-zero-ingestion`](https://prometheus.io/docs/prometheus/latest/feature_flags/) feature flag that may help here.\n\n**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.\n\nThe 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.\n\n*Telemetry makes the receipt sharp. The quality of the work stays out of frame.*\n\nClaude Code has [beta tracing](https://code.claude.com/docs/en/monitoring-usage) 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.\n\nAll 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](https://github.com/open-telemetry/semantic-conventions-genai), 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.\n\nThe 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.", "url": "https://wpnews.pro/news/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in", "canonical_source": "https://dev.to/gabrielanhaia/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in-grafana-90k", "published_at": "2026-10-18 23:03:43+00:00", "updated_at": "2026-10-18 23:19:22.397602+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "mlops", "developer-tools", "ai-infrastructure"], "entities": ["Claude Code", "Anthropic", "OpenTelemetry", "Prometheus", "Grafana", "Docker Compose"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in", "markdown": "https://wpnews.pro/news/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in.md", "text": "https://wpnews.pro/news/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in.txt", "jsonld": "https://wpnews.pro/news/claude-code-already-emits-opentelemetry-who-spends-what-on-which-model-in.jsonld"}}