Agentic coding tools like Claude Code, OpenAI Codex, Google Antigravity, and Cursor have become ubiquitous for everyday software development.
As agentic systems mature, much of the work developers have them do is delegated, one subagent at a time. Many teams are also exploring and using a shared, multi-tenant Agentic Infrastructure, where cost isn't tied to a single owner. That's where Observability becomes key to monitoring infrastructure costs.
In this guide, you'll learn how observability works, then enable Claude Code's built-in telemetry, run a backend to collect it, and read the metrics, logs, and traces it emits. This will help you start tracking your team's costs more effectively, and it'll only improve as emitted telemetry matures and correlates more cleanly with your sessions.
Note: In its current state, the emitted telemetry from Claude Code provides no attributes that allow a reliable map to named sessions. Usage can be tracked using session_id, but it's still clumsy in a longer session mixing multiple prompts/skills.
This guide is scoped to Claude Code's telemetry for metrics, logs, and tracing. Note that it applies to Linux and macOS only.
Table of Contents
Observability with OpenTelemetry
Observability is the ability to answer questions about a system's runtime behavior from the data it emits. You do this without looking into its internals, attaching a debugger, reading source code, or manually trying to reproduce the behavior.
Here, a system's runtime behavior means what's externally visible. You can ask questions like:
How much time 95% of all requests take.
What the failure rate is across all requests received.
What the cache hit ratio is for the in-memory cache the service uses.
The difference between the configured and deployed replica counts for a service.
For Claude Code, the inaccessible inner workings are: how it manages context, how work is divided across multiple LLM calls, and how subagents are orchestrated. But you can read the emitted telemetry from Claude code to answer questions like:
How much a dev or a team spent over a day, week, or month.
How that usage is distributed across the supported models and effort levels.
How many tokens are spent per dollar, and how much that varies by type (input, output, cacheRead, cacheCreation).
When a compaction event kicked in, and by how much it reduced the context's token usage.
Only an instrumented system can answer these questions. Instrumentation is a piece of code added by the developer or built into the tool that records a program's runtime behavior and emits it as telemetry. For example, a measurement like this request spent 100 tokens.
The telemetry data helps avoid silent failures by providing a well-structured data trail of the system's behavior over time. For example, here's a chart from GitHub's August 17, 2026 outage postmortem explaining a rise in GitHub Actions runs over time from ~30M to ~110M:
Telemetry Data
The emitted telemetry consists of three categories of data:
Metrics: Numeric measurements aggregated over a time window, like queries per second (QPS).
Logs: A detailed record of an individual event, with a timestamp. For example, a compaction event in Claude Code.
Traces: The path of one request through the system, split by time into nested requests. For example, an order-placement request on an ecommerce website, showing which internal services it calls to complete the request.
OpenTelemetry (https://opentelemetry.io/) is an observability framework that helps you generate, collect, and export these signals to a backend, which handles storage, querying, and visualization. Keeping that split makes it tool/vendor agnostic where the backend can be open-source or proprietary. It provides instrumentation SDKs for multiple programming languages.
Instrumenting Claude Code
Instrumentation code usually runs alongside the application, at the points best for measuring: a middleware with a request arriving or a response going out, or a token being counted.
It is plugged into the application in two ways:
A Shared Instrumentation Library: Applications using an open source framework can add an instrumentation library as a dependency and link it with the application's lifecycle methods. OpenTelemetry publishes instrumentation libraries for many frameworks (Example: Spring Framework).
Customized Implementation by Application Developers: Telemetry data emitters are added directly in the codebase using the OpenTelemetry SDK. For a closed-source product, the code is private, but it can still emit telemetry data compatible with OpenTelemetry standards.
Example: HTTP Instrumentation
Middleware in HTTP handling is a common codepath for all requests, which is why it's chosen for application-wide settings like authentication. The same reason makes it a good place for instrumentation code: wrap the handler once, measure every request.
The diagram shows where instrumentation sits relative to the request path.
A client request passes through an HTTP middleware before reaching the application's request handler and downstream calls. The middleware uses SDK constructs to time the handler and record duration, status, and a count.
The SDK then buffers those measurements and pushes OTLP to the Collector on a background thread, off the request path.
Claude Code is the second case, where both the core app and its instrumentation module are provided by Anthropic. The code that measures token usage, cost, and tool calls is built in and emits OpenTelemetry over OTLP. As a Claude Code user, you only need to enable the telemetry and prepare a backend to receive and analyze it.
Note: OTLP is a telemetry data delivery protocol designed in the scope of the OpenTelemetry project. This guide assumes end-to-end compatibility with OTLP. Wiring up incompatible telemetry or using incompatible backend components can give unpredictable results and is out of scope.
To collect, store, and read the telemetry, you'll need the following components:
OpenTelemetry Collector: A vendor-agnostic implementation of how to receive, process, and export telemetry data. This is optional but great to have for personal setup. Must have for a production use case.
Jaeger: Distributed tracing backend, released as an open source tool by Uber.
Prometheus: Collects and stores metrics as Timeseries Data.
Loki: Scalable Log Aggregation system by Grafana.
Grafana: for UI visualization of logs, metrics, and traces.
Pull vs Push: How Telemetry Leaves an App
Telemetry leaves an application in one of two ways:
Pull (Scrape): The app exposes its current metrics on an HTTP endpoint, and a scraper (Prometheus) reads that endpoint periodically. Every running instance needs its own port, and the scraper must know all of those addresses ahead of time. The app is passive: the scraper drives the data movement. This suits long-lived processes with stable addresses.
In OpenTelemetry, pull-based scraping is configured with OTEL_METRICS_EXPORTER=prometheus (see the SDK environment variables for the accepted exporter values).
By convention, the app makes its metrics available at http://localhost:9464/metrics. This exporter handles metrics only.
Push (OTLP): The app sends its telemetry to a receiving endpoint on a regular interval. This is more flexible: any number of processes can push to the same endpoint with no registration ahead of time, so apps can start and stop freely even as their addresses change.
In OpenTelemetry, push is configured with OTEL_METRICS_EXPORTER=otlp, which ships over the OTLP exporter.
The push model can carry metrics, logs, and traces.
When to Run a Collector
A Collector is deployed when the existing stack isn't enough to handle the system's growing scale and complexity. It helps in the following ways:
One export config: every producer points at the Collector instead of each carrying its own per-backend exporter setup.
Outbound-only connections: enterprise networks often block the inbound connections a pull-based scraper needs. With a Collector, the app pushes out to it and it pushes onward, so nothing has to accept inbound traffic.
Fan-out and translation: the Collector can convert telemetry into a vendor's storage format and send the same signal to more than one backend.
Buffering: if a backend goes down, the Collector holds the data and retries, absorbing transient failures.
Processing: it can apply processors before data leaves for storage, such as redacting attributes.
Note: This guide runs a push-based configuration with a Collector even though the setup is single-user. The stack has three backends that store and query data differently, and letting the Collector receive Claude Code's OTLP once and route each signal to the right place is simpler than wiring the app to all three. That's why the tool list calls it optional for personal use but a must-have in production: its value grows with the number of producers and backends.
Prerequisites
Each section in this guide links relevant docs, but you'll make faster progress if the tools and query languages specified below are already familiar:
You'll need
Knowledge that would help
Docker Compose: bringing up containers defined in compose file, reading container status and logs by docker compose ... commands.
Bash and Config files: Setting environment variables, editing JSON files.
PromQL (Prometheus): How to use counters/gauges, range selectors, and sum / increase / rate / by (label) grouping.
Grafana: Explore a datasource, build dashboards with stat and timeseries panels. Panel transformations and Global variables.
LogQL (Loki): stream selectors, logfmt, and label_format.
Jaeger and tracing: the trace/span model (parent-child spans, span count, duration) and the Jaeger UI's tag search.
Claude Code's execution model: sessions, subagents, skills, tools, and context compaction.
Claude billing basics: tokens and the prompt-caching tiers.
Setup
The test observability stack is deployed using Docker Compose. For telemetry export to work, Claude Code must be able to reach Collector's OTLP endpoint which is localhost:4317 (gRPC) or localhost:4318(HTTP) when running on same machine. All backend services run in a container with their own Docker volume for persistence.
The diagram shows how the telemetry components we're using are linked.
Apart from Claude Code, every component runs in a container managed by Docker Compose.
Multiple instances of Claude Code running on any machine (host or cloud VM) should be able to export telemetry to the Collector as long as those machines have connectivity to it (ports 4317/4318 of the host running the Collector container are reachable).
Moving from top to bottom:
Claude Code exports all three signals over OTLP to the Collector.
The Collector then splits them by type, pushing traces to Jaeger and logs to Loki, while exposing metrics on port 8889 for Prometheus to scrape.
Jaeger, Prometheus, and Loki each persist to their own Docker volume.
Grafana queries all three as the single dashboard layer.
The goal here is a live stream of telemetry from Claude Code that gets stored in a backend and can be queried on demand, during a session or long after. Two things must be in place:
Enable telemetry in Claude Code. The instrumentation is built in but emits nothing until telemetry is enabled and its OTLP exporter points at the Collector.
Run the observability backend. The Collector processes each signal, forwarding it to Prometheus, Loki, and Jaeger for storing and serving queries.
You'll start the backend first, so the telemetry has somewhere to go.
Start the Observability Backend
Before enabling telemetry in Claude Code, ensure the stack is up to collect, process, and read the data. The code for the test observability backend lives in this Github repo.
The repo has the following structure:
.
├── README.md
└── compose
├── docker-compose.yml # Docker config for 5 containers in the observability stack. Applies pinned image versions, port mappings and named volumes for each service.
├── grafana
│ └── provisioning
│ ├── alerting
│ ├── dashboards
│ ├── datasources # datasources(Prometheus, Loki, Jaeger) and dashboards. Empty initially.
│ └── plugins
├── jaeger-config.yaml # Jaeger v2, badger (local-file) storage for traces. Ties to the user: root TIP below.
├── loki-config.yaml # single-binary Loki, filesystem storage. Near default settings.
├── otel-collector-config.yaml # receive/process/export pipeline: OTLP in on 4317/4318, traces out to Jaeger, logs to Loki, metrics exposed on :8889 for Prometheus.
└── prometheus.yml # a single scrape job against the Collector's :8889, 30s interval.
You'll only need docker compose command to start the containers. It reads docker-compose.yml and starts the containers, linking them to their respective config files.
Connectivity between containers:
All containers start within the same Docker network, which allows them to communicate using container names directly. For example, collector's exporter config uses container names:
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
otlphttp/loki:
endpoint: http://loki:3100/otlp
Note that it doesn't contain Prometheus config, since Prometheus ends up scraping it from the collector as configured in prometheus.yml:
global:
scrape_interval: 30s
scrape_configs:
- job_name: otel-collector
static_configs:
- targets: ["otel-collector:8889"]
Start the containers using Docker compose:
git clone https://github.com/ps-mir/otel-dev-stack.git
cd otel-dev-stack/compose
docker compose up -d
# Output
✔ Volume compose_loki_data Created 0.0s
✔ Volume compose_grafana_data Created 0.0s
✔ Volume compose_prometheus_data Created 0.0s
✔ Volume compose_jaeger_data Created 0.0s
✔ Network compose_default Created 0.1s
✔ Container compose-prometheus-1 Started 4.1s
✔ Container compose-loki-1 Started 4.2s
✔ Container compose-jaeger-1 Started 4.3s
✔ Container compose-otel-collector-1 Started 3.3s
✔ Container compose-grafana-1 Started 2.7s
Check container status:
# all five services should show "Up"
docker compose ps
# Output
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
compose-grafana-1 grafana/grafana:13.2.0 "/run.sh" grafana 3 minutes ago Up 3 minutes 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp
compose-jaeger-1 cr.jaegertracing.io/jaegertracing/jaeger:2.20.0 "/go/bin/jaeger --co…" jaeger 3 minutes ago Up 3 minutes 0.0.0.0:16686->16686/tcp, [::]:16686->16686/tcp
compose-loki-1 grafana/loki:3.7.6 "/usr/bin/loki -conf…" loki 3 minutes ago Up 3 minutes 0.0.0.0:3100->3100/tcp, [::]:3100->3100/tcp
compose-otel-collector-1 otel/opentelemetry-collector-contrib:0.159.0 "/otelcol-contrib --…" otel-collector 3 minutes ago Up 3 minutes 0.0.0.0:4317-4318->4317-4318/tcp, [::]:4317-4318->4317-4318/tcp, 55679/tcp
compose-prometheus-1 prom/prometheus:v3.11.2 "/bin/prometheus --c…" prometheus 3 minutes ago Up 3 minutes 0.0.0.0:9090->9090/tcp, [::]:9090->9090/tcp
TIP: Jaeger runs as user: root (Compose file) to create the badger dir. Not doing so causes a failure: mkdir /badger/key: permission denied. Jaeger itself doesn't need root permission, but Docker volumes are owned by root:root on first mount.
Enable Telemetry
OpenTelemetry instrumentation, once added to an application, stays disabled until specific configuration enables it.
There are two ways to enable telemetry in Claude Code:
1. Environment Variables
Setting specific environment variables enables telemetry generation. Beyond the standard OTEL_* variables, Claude Code defines its own CLAUDE_CODE_* variables.
This guide uses the following settings:
# master switch: when unset or 0, Claude Code produces no telemetry at all
export CLAUDE_CODE_ENABLE_TELEMETRY=1
# opt into the beta enhanced-telemetry attributes and events (extra session and tool detail)
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
# per-signal exporter selection; "otlp" ships the signal over OTLP.
# other accepted values are "console" (print locally), "prometheus" (metrics only), and "none" (drop the signal)
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_TRACES_EXPORTER=otlp
# OTLP transport: "grpc" talks to the collector's 4317 port; "http/protobuf" would use 4318
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# one endpoint for all three signals: the collector's OTLP listener on the local machine
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# how often metrics are flushed, in milliseconds; the default is 60000 (60s),
# shortened here so a manual check sees fresh data without a long wait
export OTEL_METRIC_EXPORT_INTERVAL=5000
# emit cumulative counters instead of delta (see "Aggregation Temporality" below)
export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative
Environment variables, however, are process-wide and can affect more than Claude Code. For example,
2. Claude Code settings.json
OpenTelemetry defines Declarative Config, a YAML based configuration to enable telemetry, but Claude Code doesn't support it. But it lets you set the same env variables in ~/.claude/settings.json. That isn't declarative config, but it's better than shell environment variables because it applies only to Claude Code. An example:
{
"effortLevel": "medium",
"tui": "fullscreen",
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
"OTEL_METRIC_EXPORT_INTERVAL": "5000",
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": "cumulative"
}
}
Only the env block matters for telemetry. effortLevel and tui are unrelated settings you may already have. The variables match the annotated list above.
Aggregation Temporality
Prometheus counter type metrics only increase over time. Their raw value isn't useful, so you read them through per-second growth (rate()) or total growth over a time window (increase()).
Aggregation temporality decides what number a counter reports on each telemetry export: the change since the previous export(Delta), or the running total since the process started(Cumulative).
A short example. Say Claude Code spends tokens over four 5-second export intervals:
| Export at |
Tokens since last export |
Delta value sent |
Cumulative value sent |
| 0s (start) |
-- |
-- |
0 |
| 5s |
100 |
100 |
100 |
| 10s |
0 |
0 |
100 |
| 15s |
250 |
250 |
350 |
| 20s |
50 |
50 |
400 |
By default, Claude Code emits metrics with AggregationTemporality: Delta. This can be inspected and confirmed from the Collector's container logs using the command:
# Command only works from directory containing docker-compose.yml
docker compose logs otel-collector
Note: To enable detailed logs in the Collector, you need to add the debug exporter to the Collector config.
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger, debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus, debug]
Then restart the container:
# Command only works from directory containing docker-compose.yml
docker compose up -d --force-recreate otel-collector
Log output with AggregationTemporality: Delta:
otel-collector-1 | Descriptor:
otel-collector-1 | -> Name: claude_code.active_time.total
otel-collector-1 | -> Description: Total active time in seconds
otel-collector-1 | -> Unit: s
otel-collector-1 | -> DataType: Sum
otel-collector-1 | -> IsMonotonic: true
otel-collector-1 | -> AggregationTemporality: Delta <---
otel-collector-1 | NumberDataPoints #0
Delta doesn't work well with Prometheus functions like rate()/increase(), since they expect cumulative values.
Setting OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative switches the exported metrics from delta to cumulative temporality. It has been added to both the env and JSON config used in this guide.
As before, you need to restart the Collector container after any config change for it to take effect.
Exploring Telemetry
With telemetry flowing, you can start querying it. Metrics, logs, and traces each answer a different kind of question about Claude Code usage, so the three sections below are largely independent.
The data behind them comes from two places.
The Metrics and Logs sections query whatever Claude Code usage has accumulated in the backend, so your panels will show your own sessions and the numbers won't match the screenshots. Give it a few real sessions before expecting much to show.
The Tracing section instead walks a single deliberate run, a custom skill summarizing a batch of meetings, described in enough detail to follow along. You don't need to reproduce it.
Metrics
Metrics are the aggregate, time-windowed view of Claude Code usage. Example: total cost, token volume, and how each trends and breaks down by attributes like model, effort, and token type. Use them to watch spend and spot shifts in consumption.
Each metric is a numeric measurement recorded over time, a time series of timestamped values you can plot or aggregate. Claude Code's metrics are running totals (counters), so a query reports the change over a chosen window rather than the raw value. See Aggregation Temporality above for how that works.
Prometheus is the metrics backend we're using here. It scrapes the Collector, stores the series, and answers queries written in PromQL. Grafana reads the same data for dashboards at localhost:3000. The full list of metrics and their attributes is in the Claude Code monitoring docs.
Open Prometheus in your browser (localhost:9090), type claude in the query field, and you should see the supported metrics:
For each metric below, you'll explore it first using PromQL, and then use the same query to add it to the Grafana dashboard as a panel.
Total USD Spent
claude_code_cost_usage_USD_total represents cumulative usage cost, in USD, tracked per session. It's useful for controlling budgets and spotting sudden spikes in usage.
This is a client-side estimate based on token counts priced at Anthropic's per-model, per-type rates and accumulated. It's completely normal for it to exceed your Claude Code plan's subscription cost.
Note: This metric is more critical if you're paying per raw API call. A subscription gives you a usage allowance with increased but bounded rate limits.
Test the following query in Prometheus first (localhost:9090/query):
sum(increase(claude_code_cost_usage_USD_total[10m]))
increase(...[10m]) gives the counter's growth over the last 10 minutes. sum(...) with no by clause collapses the per-attribute series (model, effort, and others) into one number.
For a Grafana panel, swap the fixed [10m] window for the $__range built-in variable so the value follows the dashboard's time picker:
sum(increase(claude_code_cost_usage_USD_total[$__range]))
To add it as a panel, open Explore, select Prometheus as the data source, and run the query. The result depends on how much you've used Claude Code in the window.
On adding to the dashboard you should get more Panel Options. Select the Stat panel:
This panel needs to be added to the Grafana dashboard.
Total Token Usage
claude_code_token_usage_tokens_total is the cumulative token count, with the same counter shape as the USD cost metric. Read a raw series in Prometheus first to see which labels you can aggregate by. A single series looks like:
claude_code_token_usage_tokens_total{effort="high", exported_job="claude-code", instance="otel-collector:8889", job="otel-collector", model="claude-sonnet-5", otel_scope_name="com.anthropic.claude_code", otel_scope_version="2.1.252", query_source="auxiliary", session_id="e0b9795b-4da3-4171-8fa6-a2866bf44d86", terminal_type="ssh-session", type="cacheCreation"} 213730
The trailing number is the counter value. Key attributes you'll be working with are type, model, and effort. The Token Usage by Type chart below groups on type.
The window total is the same query shape as Total USD Spent, with the token counter:
sum(increase(claude_code_token_usage_tokens_total[$__range]))
Tokens Per USD
Unlike the previous two metrics, this is a derived figure, calculated over a time window as Total Tokens / Total Cost.
sum(increase(claude_code_token_usage_tokens_total[$__range])) / sum(increase(claude_code_cost_usage_USD_total[$__range]))
This one number collapses every attribute combination into a single value. Each distinct combination, for example model A at medium effort versus model B at high effort, is its own time series, and the query sums across all of them.
To analyze a specific combination, run the same ratio split by an attribute and compare.
sum by (model) (increase(claude_code_token_usage_tokens_total[$__range]))
/ sum by (model) (increase(claude_code_cost_usage_USD_total[$__range]))
Swap model for effort or type; the raw series above lists the rest of the labels.
Overall Result:
Stats Panel(6hr window): Total USD Spent ($4.28), Total Tokens Spent (3.02M), and Tokens Per USD (707k).
Token Usage by Type
You'll see how claude_code_token_usage_tokens_total changes over time, broken down by type. A single stat hides the shape, so use a time-series panel.
The type attribute has four values, which differ a lot in cost:
cacheRead: tokens served from an existing cache entry. They dominate token spend in a long session, and are cheaper than the baseline rate.
cacheCreation: tokens written into the prompt cache on the first prefix load. Costly.
input: new, uncached prompt tokens.
output: model-generated tokens.
To also see the total, use two queries: the per-type breakdown and the un-split total for reference.
# per-type breakdown
sum by (type) (increase(claude_code_token_usage_tokens_total[$__rate_interval]))
# total
sum(increase(claude_code_token_usage_tokens_total[$__rate_interval]))
__rate_interval is Grafana's per-step window for time-series panels, the counterpart to the __range used for the stat panels above.
The two queries running in Explore, before saving them as a panel.
After adding to the dashboard:
Each spike is a burst of Claude Code activity, the flat stretches are idle time. Hovering a point splits the total into the four types: here the total is about 1.0M tokens, of which cacheRead is about 925k (roughly 92%), the rest cacheCreation, output, and input.
TIP: Token consumption is dominated by cacheRead, which is also the cheapest type.
Token Usage by Model and Effort
This is the concrete version of the breakdown suggested under Tokens Per USD: which model and effort pairs are actually consuming tokens.
sum by (model, effort) (
increase(claude_code_token_usage_tokens_total[$__rate_interval])
)
Here the token counter is grouped by its model and effort attributes. In this window every series is claude-sonnet-5 at either medium or high effort, and one burst of medium effort near 18:13 reaches about 2.6M tokens. The number of unique groupings depends on the cardinality of the chosen attributes.
Logs
A log record is a timestamped event with its full field set attached. You query logs when you want that specific event and the context around it: what happened, when, and with which values.
Metrics are the pre-aggregated form of the same activity. Anything that means counting, summing, or taking percentiles across many records belongs in a metric. If you're aggregating log output downstream, that data should have been a metric from the start.
Logs are the right tool for:
Per-event context: the full detail of one occurrence, not a rolled-up number.
Discrete or irregular events: a compaction firing, a session start, or an API error.
Post-incident forensics: reading raw records back while debugging after the fact.
Trace correlation: a log line carrying a trace and span ID drops you into the request it came from.
The log backend we're using here is Loki, queried with LogQL. Running logs through a backend like this buys you:
Structured fields: filter and compute on named keys instead of regex over text.
Field indexing: label lookups return without scanning every line.
Trace and span correlation: pivot from a log to its trace, or pull every log for one trace.
Time-bounded queries: each query is scoped to a window, keeping the scan cheap.
Grafana reads Loki for dashboards, the same as it does for Prometheus.
Compaction Event
Compaction is Claude Code trimming its own context when it grows too large. Each compaction emits a log event (event_name="compaction") carrying the token counts before and after (pre_tokens, post_tokens) and the span_id it happened under, so a query over those events shows how often it fires and how much it reclaims each time.
In Grafana Explore, select Loki as the data source and paste the LogQL below. It selects the compaction events, parses their fields with logfmt, and derives a per-event reduction percentage with label_format. The fields only exist once compaction has actually happened, so trigger a few first.
{service_name="claude-code"} | event_name="compaction"
| logfmt
| label_format reduction_pct=`{{ printf "%.1f" (mulf (divf (subf .pre_tokens .post_tokens) .pre_tokens) 100) }}`
Deriving reduction_pct for each record is fine here because it stays per-event. A running average across compactions would belong in a metric.
The label_format line adds a reduction_pct label. To show it as a table, switch the panel to Table view and add three Grafana transformations:
Extract fields from the labels object.
Filter fields by name to keep Time, pre_tokens, post_tokens, reduction_pct, and span_id.
Convert field type to turn pre_tokens, post_tokens, and reduction_pct into numbers.
Tracing
Tracing provides a detailed picture of the full path a request takes through an application, from start to completion. Some fundamental concepts behind tracing:
Span: a timed operation representing a unit of work. Building block for traces. All trace data is recorded as a sequence of spans, and each has a type, its operation name:
claude_code.interaction: one prompt and everything Claude Code does to answer it. Normally the root span, so one interaction is effectively one trace.
claude_code.llm_request: a single model call inside an interaction.
claude_code.tool: a single tool call inside an interaction (Bash, Write, Agent, and so on).
Trace: a tree of spans representing a request path from start to completion.
Session: one Claude Code run, identified by session.id. It can result in many interactions and traces.
Subagent: a nested Claude Code instance started by the Agent tool, running its own interactions.
Jaeger is the tracing backend used here. The Collector forwards spans to it over OTLP. Jaeger stores them and lets you search traces by service and span tags and inspect each one as a span tree. Everything below uses its UI at localhost:16686.
Generating Traces
To generate trace data, this guide will use a test prompt to spawn agents and prepare some text. This prompt was tested with Sonnet 5 at medium effort.
You can paste the prompt directly into Claude Code:
Spawn 4 subagents in parallel, one per topic below. Each subagent researches its topic from your own knowledge and returns a ~150-word summary with 3 key points. Do not have them read files or run commands.
Topics:
1. How TCP congestion control works
2. The CAP theorem
3. How DNS resolution works
4. What a Bloom filter is
Once all 4 return, combine the summaries into one markdown document and write it to summary.md
Note: Ask Claude Code for the session_id in the same session after the prompt finishes. This will be used to find related traces in Jaeger.
Trace By Session ID
The search filters by service = claude-code and the tag session.id=<id>. It returns 6 traces, all rooted at claude_code.interaction, with span counts from 1 to 20 and durations from about 1 second to 33 seconds.
The list alone doesn't say which trace did what. Going through them by hand, or scripting it against the trace API for a real session, gives the following:
| # |
Trace Name |
Spans |
Duration |
llm_calls |
tools |
| 1 |
claude_code.interaction |
1 |
1.4s |
0 |
– |
| 2 |
claude_code.interaction |
3 |
4.6s |
2 |
– |
| 3 |
claude_code.interaction |
1 |
5.4s |
0 |
– |
| 4 |
claude_code.interaction |
1 |
2.5s |
0 |
– |
| 5 |
claude_code.interaction |
20 |
15.7s |
7 |
Agent(x4) |
| 6 |
claude_code.interaction |
15 |
32.5s |
5 |
ScheduleWakeup(x2), Write(x1) |
A few observations:
Half the traces are noise. Traces 1, 3, and 4 are single-span interactions with no model call or tool, an idle session being pinged. Trace 2 is a brief exchange. Only Traces 5 and 6 are the run.
The parallel dispatch is a single interaction. Trace 5 fires all four Agent calls inside one claude_code.interaction. Their nested model calls (7 to 10 seconds each) overlap, so the interaction finishes in about 16 seconds despite roughly 35 seconds of combined subagent LLM time.
Each subagent's model call is nested under its Agent span and carries an agent_id, so you can tell the four apart.
agent_id is opaque. There's no agent.name or skill.name. The trace tells you four subagents ran and how long each took, not which topic each was given.
Spans carry token counts but no USD cost. Each claude_code.llm_request has input_tokens, output_tokens, cache_read_tokens, and cache_creation_tokens, but no USD figure.
The write is a separate, later interaction. Trace 6 has no Agent spans: one claude_code.llm_request of about 23 seconds produces the combined markdown, then a short Write. The two ScheduleWakeup spans are background coordination.
TIP: From the trace you get the four subagent calls, each with an agent_id, token counts, and timing, but no span says which topic a subagent was handed. In contrast, Metrics can provide attribution by model, effort, and skill.
CAUTION: user_prompt is redacted by default on interaction spans. OTEL_LOG_USER_PROMPTS=1 disables this and logs raw prompt text. Avoid enabling it in multi-user/tenant environments since it exposes prompt content to anyone with access to the telemetry backend.
Conclusion
This guide was an end-to-end walkthrough of observability in Claude Code, enabling its telemetry and collecting each of the three signals in a local backend for analysis.
Metrics give you the ability to dissect cumulative cost and usage readings by attribute over a chosen time period. That matters most in a shared or multi-tenant setup, where cost isn't tied to a single owner and someone still has to account for it.
Logs are records of individual events, useful for digging into exactly what changed during one, like compaction.
Traces show how one prompt expands into subagents and model calls, with timing and token counts on each. That's the starting point for debugging or tightening a complex or multi-agent prompt, though the spans don't yet record which prompt or skill drove a given call.
Some of this telemetry is behind the Enhanced Telemetry beta, so span names and attributes can still change, and gaps like per-call attribution may close as it matures. It's worth re-checking the monitoring docs as the surface settles.
References