AI coding agents consume tokens in ways that are difficult to see. A single request in Claude Code can start a subagent, and that subagent can call several tools, each of which sends more context back to the model. The developer reads the final answer. The invoice shows a monthly total. Neither one explains which agent or which model produced the cost.
Claude Code can export this information as OpenTelemetry metrics and log events. Metrics answer the live question, which is what the agent is spending right now on this machine. Log events answer the historical question, which is how the cost and the tool behavior of many developers changed over the last two weeks. The two questions suit two different tools.
This article builds a viewer for both. A Docker Compose stack on the developer machine runs an OpenTelemetry Collector, Prometheus and Grafana, so the metrics appear in a dashboard within seconds. The same collector forwards the log events to a Microsoft Fabric Eventstream. An Eventhouse stores them, and a Real-Time Dashboard reads them with KQL for the team view.
A note on scope. The Compose file, the collector configuration and the Grafana provisioning files were checked for syntax only. The Fabric steps follow the product documentation. Run each stage against a real Claude Code session and compare the result with the descriptions here. The dashboard images in this article use illustrative data.
What Claude Code emits
Claude Code exports telemetry through the standard OpenTelemetry environment variables. The documentation lists eight metrics. The following table shows the ones that this article uses.
| Metric | What it counts | Attributes used here |
|---|---|---|
claude_code.cost.usage | Cost of the session in USD | model, query_source, agent.name |
claude_code.token.usage | Tokens used | type (input, output, cacheRead, cacheCreation), model |
claude_code.session.count | Sessions started | start_type |
claude_code.lines_of_code.count | Lines of code modified | type (added or removed) |
claude_code.code_edit_tool.decision | Accept or reject decisions on edits | decision, tool_name |
The log events carry more detail, because Claude Code writes one event for each occurrence instead of a running total. Two events matter for cost and reliability. The claude_code.api_request event describes one request to the model, with the attributes model, cost_usd, duration_ms, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens and query_source. The claude_code.tool_result event describes one tool call, with tool_name, success and duration_ms.
The attribute query_source is the one that turns a cost report into an agent report. It identifies the part of the system that issued a request. The main conversation and each subagent have different values, and background work such as context compaction has its own value, so the cost of delegation becomes visible.
Prompt text is a separate matter. Claude Code redacts prompts, assistant responses and tool parameters by default, and each has its own opt-in variable. This article leaves all of them disabled. The user.id attribute is a random anonymous identifier and is not derived from the account.
Enable telemetry
Telemetry is off until the variable CLAUDE_CODE_ENABLE_TELEMETRY is set to 1. The following file sets the exporters, the protocol and the endpoint that the collector will expose. A shell that sources it starts every later Claude Code session with telemetry enabled.
# Source this file in the shell that starts Claude Code: source claude-telemetry.env 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_METRIC_EXPORT_INTERVAL=10000 export OTEL_LOGS_EXPORT_INTERVAL=5000 # Prompts, responses and tool parameters stay redacted unless you opt in. Leave these unset. Claude Code has no default protocol, so the file sets OTEL_EXPORTER_OTLP_PROTOCOL explicitly. The short metric interval of ten seconds is useful during setup. A longer interval reduces the number of samples once the stack works as intended.
Run the collector, Prometheus and Grafana
The Compose file starts three containers. The collector receives OTLP from Claude Code. Prometheus scrapes the metrics that the collector exposes. Grafana reads Prometheus and loads a dashboard from provisioning files.
services: otel-collector: image: otel/opentelemetry-collector-contrib:latest command: ["--config=/etc/otelcol/config.yaml"] environment: FABRIC_KAFKA_BOOTSTRAP: ${FABRIC_KAFKA_BOOTSTRAP:-localhost:9093} FABRIC_KAFKA_TOPIC: ${FABRIC_KAFKA_TOPIC:-claude-events} FABRIC_KAFKA_CONNECTION_STRING: ${FABRIC_KAFKA_CONNECTION_STRING:-unset} volumes: - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro ports: - 4317:4317 - 4318:4318 prometheus: image: prom/prometheus:latest command: ["--config.file=/etc/prometheus/prometheus.yml"] volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus ports: - 9090:9090 depends_on: - otel-collector grafana: image: grafana/grafana-oss:latest environment: GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Admin volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana ports: - 3000:3000 depends_on: - prometheus volumes: prometheus-data: grafana-data: The collector configuration defines one pipeline for each signal.
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: # Prometheus needs cumulative counters. This processor converts delta sums. deltatocumulative: {} batch: timeout: 5s exporters: # Local: Prometheus scrapes this endpoint, Grafana reads Prometheus prometheus: endpoint: 0.0.0.0:8889 # Team-wide: Fabric Eventstream through its Kafka-compatible custom endpoint kafka/fabric: brokers: - ${env:FABRIC_KAFKA_BOOTSTRAP} logs: topic: ${env:FABRIC_KAFKA_TOPIC} encoding: otlp_json tls: insecure: false auth: sasl: mechanism: PLAIN username: $$ConnectionString password: ${env:FABRIC_KAFKA_CONNECTION_STRING} service: pipelines: metrics: receivers: [otlp] processors: [deltatocumulative, batch] exporters: [prometheus] logs: receivers: [otlp] processors: [batch] exporters: [kafka/fabric] Several details in this file deserve attention. The deltatocumulative processor converts delta metrics to cumulative ones by keeping a running total in memory, and its documentation lists the stability level as alpha. The processor is present because the Prometheus exporter serves cumulative values, and the documentation that I read does not state the temporality that Claude Code uses by default. The SASL username contains a literal dollar sign, so the configuration escapes it as $$ConnectionString. The encoding value otlp_json makes each Kafka message a readable JSON document that the Eventhouse can ingest later.
Prometheus needs only one scrape target.
global: scrape_interval: 15s scrape_configs: - job_name: claude-code static_configs: - targets: ["otel-collector:8889"] Start the stack from a folder named claude-telemetry, which also becomes the Compose project name.
docker compose up -d source claude-telemetry.env claude Without Fabric values in a .env file, the Kafka exporter logs connection retries. The metrics pipeline continues to work, because each pipeline has its own queue. Comment out the logs pipeline if the retry messages distract during the first test.
Chart agent cost and tokens in Grafana
Grafana reads its configuration from files, so the dashboard exists as soon as the container starts. Two small files connect it to Prometheus and tell it where to find dashboard JSON.
apiVersion: 1 datasources: - name: Prometheus uid: prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true apiVersion: 1 providers: - name: claude-code folder: Claude Code type: file options: path: /var/lib/grafana/dashboards The dashboard file grafana/dashboards/claude-code-agents.json holds eight panels. Each one is a short PromQL expression. The cost panel, for example, sums the increase of the cost counter per model.
sum by (model) (increase(claude_code_cost_usage_USD_total[$__rate_interval])) The panel for delegated work uses the same counter and groups by the attribute that identifies the source of each request.
sum by (query_source) (increase(claude_code_cost_usage_USD_total[$__rate_interval])) Metric names change on the way into Prometheus. The exporter normalizes names by default and can append a unit suffix and a counter suffix. Open http://localhost:9090 after the first session and search for claude_code. If the names differ from the ones in the dashboard file, edit the file and reload Grafana. The cost metric also supports attribution attributes such as agent.name and skill.name, and a query that groups by agent_name shows the cost of each named subagent when the attribute is present.
Open http://localhost:3000 to see the dashboard. The Grafana image in the Compose file is the open source edition, so no license is required.
Send the log events to Fabric
Grafana with Prometheus answers questions about the current session and the last few days, because Prometheus keeps data on the local volume. A team lead needs a longer record from many machines, and a query language that can join and rank. The Fabric half of the stack provides both.
The Fabric side has three items. An Eventstream receives the events, an Eventhouse stores them, and a Real-Time Dashboard displays them. The Eventstream accepts Kafka clients through a custom endpoint source, which is the reason the collector can use its standard Kafka exporter.
- Create an Eventhouse named
claude-telemetry. Fabric creates a default KQL database with the same name. - Create an Eventstream and add a Custom endpoint source.
- Open the source, select the Kafka tab, and choose SAS Key Authentication. The page shows the bootstrap server, the connection string for the primary and secondary keys, and the topic name.
- Copy these values into a
.envfile next to the Compose file.
# Copy to .env. Values come from the Kafka tab of the Eventstream custom endpoint. FABRIC_KAFKA_BOOTSTRAP=<bootstrap-server-from-sas-key-authentication-page> FABRIC_KAFKA_TOPIC=<topic-name-from-the-kafka-tab> FABRIC_KAFKA_CONNECTION_STRING=<connection-string-primary-key> The Fabric documentation describes the authentication settings as security.protocol of SASL_SSL, sasl.mechanism of PLAIN, a username equal to the literal text $ConnectionString, and the connection string as the password. Copy the bootstrap server exactly as the page shows it, including the port.
Before the Eventstream can write to the Eventhouse, the tables must exist. Run the following script in the KQL queryset of the database. It creates a landing table, a typed table, a function that flattens the OTLP structure, and an update policy that runs the function for every ingested batch.
.create table RawClaudeLogs (resourceLogs: dynamic) .create table ClaudeEvents ( Timestamp: datetime, EventName: string, SessionId: string, PromptId: string, Model: string, QuerySource: string, CostUsd: real, DurationMs: real, InputTokens: long, OutputTokens: long, CacheReadTokens: long, CacheCreationTokens: long, ToolName: string, Success: string ) .create-or-alter function ExpandClaudeEvents() { RawClaudeLogs | mv-expand rl = resourceLogs | mv-expand sl = rl.scopeLogs | mv-expand lr = sl.logRecords | mv-apply a = lr.attributes on ( summarize attrs = make_bag(bag_pack(tostring(a.key), a.value)) ) | extend EventName = replace_string(tostring(attrs["event.name"].stringValue), "claude_code.", "") | project Timestamp = unixtime_nanoseconds_todatetime(tolong(lr.timeUnixNano)), EventName, SessionId = tostring(attrs["session.id"].stringValue), PromptId = tostring(attrs["prompt.id"].stringValue), Model = tostring(attrs["model"].stringValue), QuerySource = tostring(attrs["query_source"].stringValue), CostUsd = todouble(coalesce(tostring(attrs["cost_usd"].doubleValue), tostring(attrs["cost_usd"].stringValue), tostring(attrs["cost_usd"].intValue))), DurationMs = todouble(coalesce(tostring(attrs["duration_ms"].doubleValue), tostring(attrs["duration_ms"].stringValue), tostring(attrs["duration_ms"].intValue))), InputTokens = tolong(coalesce(tostring(attrs["input_tokens"].intValue), tostring(attrs["input_tokens"].stringValue), tostring(attrs["input_tokens"].doubleValue))), OutputTokens = tolong(coalesce(tostring(attrs["output_tokens"].intValue), tostring(attrs["output_tokens"].stringValue), tostring(attrs["output_tokens"].doubleValue))), CacheReadTokens = tolong(coalesce(tostring(attrs["cache_read_tokens"].intValue), tostring(attrs["cache_read_tokens"].stringValue), tostring(attrs["cache_read_tokens"].doubleValue))), CacheCreationTokens = tolong(coalesce(tostring(attrs["cache_creation_tokens"].intValue), tostring(attrs["cache_creation_tokens"].stringValue), tostring(attrs["cache_creation_tokens"].doubleValue))), ToolName = tostring(attrs["tool_name"].stringValue), Success = tostring(coalesce(tostring(attrs["success"].boolValue), tostring(attrs["success"].stringValue))) } .alter table ClaudeEvents policy update @'[{"IsEnabled": true, "Source": "RawClaudeLogs", "Query": "ExpandClaudeEvents()", "IsTransactional": false, "PropagateIngestionProperties": false}]' .alter-merge table RawClaudeLogs policy retention softdelete = 1d Each Kafka message holds one OTLP export request, so each row in RawClaudeLogs holds one resourceLogs array. The function expands it level by level and converts the attributes of each log record into a property bag. OTLP JSON stores every attribute as a typed object, such as {"intValue": "412"}. Numeric attributes may arrive as strings or as typed numbers, so the function tries each form in turn. Inspect one real record in RawClaudeLogs before trusting the typed table, and adjust the attribute names if they differ.
Back in the Eventstream, add the Eventhouse as a destination. Select direct ingestion, choose the RawClaudeLogs table, and set the data format to JSON. When the mapping page appears, map the top-level resourceLogs field to the column of the same name. Publish the Eventstream, then restart the collector so that it reads the .env values.
docker compose up -d --force-recreate otel-collector After a few prompts in Claude Code, the query ClaudeEvents | take 10 should return rows. If it does not, query RawClaudeLogs | take 1 first. An empty landing table points to the Kafka connection, and a populated landing table with an empty ClaudeEvents points to the update policy or to an attribute name that differs from the one in the function.
Build the Real-Time Dashboard
Create a Real-Time Dashboard in the same workspace and add the Eventhouse database as a data source. Each tile runs one KQL query. The queries below answer six questions that engineering leads ask about agent use.
// Cost per day by model ClaudeEvents | where EventName == "api_request" and Timestamp > ago(30d) | summarize CostUsd = sum(CostUsd) by bin(Timestamp, 1d), Model | render columnchart // Cost by query source: main thread versus subagents ClaudeEvents | where EventName == "api_request" and Timestamp > ago(7d) | summarize Requests = count(), CostUsd = sum(CostUsd) by QuerySource | order by CostUsd desc // Cache hit ratio per day ClaudeEvents | where EventName == "api_request" and Timestamp > ago(30d) | summarize CacheRead = sum(CacheReadTokens), Input = sum(InputTokens) by bin(Timestamp, 1d) | extend CacheHitRatio = round(todouble(CacheRead) / (CacheRead + Input), 3) | project Timestamp, CacheHitRatio | render timechart // Model latency, median and 95th percentile ClaudeEvents | where EventName == "api_request" and Timestamp > ago(7d) | summarize p50 = percentile(DurationMs, 50), p95 = percentile(DurationMs, 95) by Model // Tool reliability ClaudeEvents | where EventName == "tool_result" and Timestamp > ago(7d) | summarize Calls = count(), Failures = countif(Success == "false"), p95 = percentile(DurationMs, 95) by ToolName | extend FailureRate = round(todouble(Failures) / Calls, 3) | order by Calls desc // Most expensive sessions ClaudeEvents | where EventName == "api_request" and Timestamp > ago(7d) | summarize CostUsd = sum(CostUsd), Requests = count(), Subagent = countif(QuerySource != "repl_main_thread") by SessionId | top 10 by CostUsd desc The queries use percentiles for latency, because the mean hides slow requests when most requests are fast. The query that groups by QuerySource is the team version of the Grafana panel for delegated work, and it keeps its history for as long as the Eventhouse retention policy allows. The cache hit ratio is the share of input that the model read from its cache. A ratio that falls after a change in prompts or tools is an early sign that the same context is being paid for repeatedly.
What the two dashboards tell you about an agent
The dashboards support four decisions about agent design.
The first decision concerns delegation. If subagents account for a large share of cost, review what each subagent loads into its context and whether the task needed delegation at all. The split between the main thread and the subagents gives the first evidence.
The second decision concerns model choice. The cost by model panel shows how much work runs on the larger model and how much on the smaller one, which is the starting point for moving routine tasks to the cheaper model.
The third decision concerns context. A low cache hit ratio means that the model reads the same material at the full input price. Changes that rewrite the start of the context on every turn lower the ratio, and the dashboard shows the day on which it began to fall.
The fourth decision concerns tools. A tool with a high failure rate or a long 95th percentile costs time, because the agent waits and often retries. The tool_result events identify the tool, and the same table can answer whether failures cluster in a few sessions.
Roll it out to a team
On a team, the endpoint should not depend on each developer setting variables by hand. Claude Code reads managed settings, and the documentation shows a configuration that sets the OpenTelemetry variables centrally. When managed settings define the endpoint, the protocol or the credentials, Claude Code removes conflicting developer values at startup, so developers cannot redirect telemetry to another collector.
{ "env": { "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector.example.com:4317" } } A shared collector replaces the local one in this setup, and it needs transport security and authentication before it accepts traffic from many machines. The exporter selector variables are not locked by the endpoint setting, so include them in the managed file as shown.
Where this leaves you
The local stack answers what an agent is spending during the current session. The Fabric stack answers how agent use changed across a team over weeks. Both read telemetry that Claude Code produces without any change to the agent itself.
Several limits apply. The deltatocumulative processor has alpha stability, so pin the collector image version once the stack works. Metric names and attribute types come from the products involved, so the dashboard JSON and the KQL function are the two places to adjust after an update. High-cardinality attributes such as the session identifier increase the size of Prometheus data, and the documentation describes variables such as OTEL_METRICS_INCLUDE_SESSION_ID that reduce it.
References
- Claude Code monitoring and usage (OpenTelemetry)
- Consume events from a Fabric Eventstream with the Kafka endpoint
- OpenTelemetry Collector Contrib: Kafka exporter
- OpenTelemetry Collector Contrib: deltatocumulative processor
- OpenTelemetry Collector Contrib: Prometheus exporter
- Grafana documentation: provision dashboards and data sources




