> For the complete documentation index, see [llms.txt](/llms.txt).
> A full single-fetch corpus is available at [llms-full.txt](/llms-full.txt).

# How do you analyze your coding sessions with OpenTelemetry traces from Claude Code?

> Send Claude Code's OpenTelemetry traces to a local Jaeger, then answer eight questions about your coding sessions with a few lines of Python each.

**Short answer:** Turn on Claude Code's OpenTelemetry tracing and send the traces to a local Jaeger. Each prompt becomes a trace: spans for every model call, tool call and permission wait, with timings, token counts and, if you turn them on, the commands and tool output. A short Python script over Jaeger's API then answers questions across sessions: where the time went, which skills loaded, which MCP tools failed, and whether Claude ran its work before saying it was done.

We use Claude Code every day to build on AGNT5. When a session was slow, or the result wasn't what we expected, we couldn't see why. We could read the conversation, but not what happened in between: which commands ran, what the tools returned, whether our skill loaded.

So we turned on Claude Code's OpenTelemetry tracing, which records every prompt, model call and tool call. One of the first things it showed us was something we could fix on our side. Claude was checking package versions online from the shell:

```
tool Bash   62ms   ls -la; pip index versions agnt5 | head -2; which agnt5 uv
tool Bash   51ms   uv pip index versions agnt5 | head -1 || curl -s https://pypi.org/pypi/agnt5/json | …
tool Bash  292ms   curl -s https://pypi.org/pypi/agnt5/json | …; agnt5 --version
```

That was a sensible thing for it to do. Our skill mentioned a version number and didn't say whether to trust it. We added one line telling Claude to use the versions in the skill, and the lookups stopped. We wouldn't have found this from the conversation, or from counting web searches: there were only 2 in three days.

This article shows how to set up the same tracing, how to read a trace, and eight questions you can answer about your own sessions, each with a few lines of Python and the output from ours.

## What a trace looks like

Each prompt you send becomes one trace. It's a tree of spans:

```
claude_code.interaction            ← your prompt → Claude's answer
├── claude_code.llm_request        ← each model call
└── claude_code.tool               ← each tool call: Bash, Read, MCP, Skill…
    ├── tool.blocked_on_user       ← time waiting for a permission decision
    └── tool.execution             ← time actually running, success true/false
```

Each span carries attributes. These are the ones this article uses:

| Span | Attributes |
|------|------------|
| `interaction` | `user_prompt` |
| `llm_request` | `model`, `query_source` (why the call was made), `input_tokens`, `output_tokens`, `cache_read_tokens`, `ttft_ms` (time to first token), `stop_reason` |
| `tool` | `tool_name`, `tool_input` (the command, file path or arguments), `skill_name` |
| `tool.blocked_on_user` | `decision` (accept or reject), `source` (your settings or you) |
| `tool.execution` | `success`, `error` |

MCP calls are tool spans named `mcp__<server>__<tool>`. A skill load is a tool span named `Skill`. A subagent's spans sit under the tool span that started it.

## Set it up

You need Docker and Claude Code. Nothing leaves your machine.

**1. Start Jaeger.** It stores the traces and gives you a UI at http://localhost:16686. Save the config from the appendix as `jaeger-config.yaml`, then run:

```bash
docker volume create jaeger-badger
docker run -d --name jaeger --restart unless-stopped --user 0 \
  -p 16686:16686 -p 4317:4317 -p 4318:4318 \
  -v jaeger-badger:/badger \
  -v "$PWD/jaeger-config.yaml:/etc/jaeger/config.yaml:ro" \
  jaegertracing/jaeger:2.23.0 --config /etc/jaeger/config.yaml
```

The config keeps traces on disk for 30 days. Jaeger's default image keeps them in memory and loses them on restart.

**2. Turn on tracing.** Add this to `~/.claude/settings.json`. It has to be your user settings. A repository's `.claude/settings.json` can't choose where traces go: we set the endpoints there and the traces still went to the one in our user settings.

```json
{
  "env": {
    "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
    "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
    "OTEL_TRACES_EXPORTER": "otlp",
    "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf",
    "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://localhost:4318/v1/traces",
    "OTEL_LOG_TOOL_DETAILS": "1",
    "OTEL_LOG_TOOL_CONTENT": "1",
    "OTEL_LOG_USER_PROMPTS": "1",
    "ENABLE_BETA_TRACING_DETAILED": "1",
    "BETA_TRACING_ENDPOINT": "http://localhost:4318"
  }
}
```

| Lines | What they do |
|-------|--------------|
| First five | Turn on tracing and send it to Jaeger. On their own they give you timings and success or failure |
| The three `OTEL_LOG_*` | Add content that is left out by default: commands and tool arguments, tool output, and your prompts |
| Last two | Turn on detailed tracing: the full input of every tool call, the model's response text and what was sent to the model each turn |

We ran the same prompt at each level to see what you get:

| Settings on | Spans contain |
|-------------|---------------|
| First five  | Timings, tool names, success or failure, token counts. The prompt is recorded as `<REDACTED>` |
| First eight | Also the prompt text, the Bash command (`full_command`), file paths and tool output |
| All ten     | Also `tool_input` for every tool (MCP arguments included), the model's response text, the system prompt and the list of tools offered |

Your email and account IDs are on every span at every level.

Detailed tracing is the heaviest setting. Anthropic's docs say to use it for debugging only, and that it exports a lot of data. It works as is in `claude -p` and the Agent SDK. In interactive terminal sessions, the docs say your organization has to be allowlisted for it. Without it, the snippets below still work, except question 6: it compares tool inputs, which are no longer recorded.

**3. Send a prompt.** Restart Claude Code, send any prompt, then open Jaeger and pick the service `claude-code`.

## Read one trace

Here is one real prompt. It ran a Bash command, read a file and called an MCP server: 7.1 seconds, 16 spans.

**The timeline.** The three model calls take 2.7 s, 1.3 s and 3.0 s. The four tool calls take 4–117 ms each.

![Trace timeline in Jaeger: one interaction span with three model calls and four tool calls under it](/articles/claude-code-opentelemetry-traces/1-trace-timeline.jpg)

**A tool call.** Click a tool span to see its attributes: the exact command and what it returned.

![A Bash tool span in Jaeger, showing the command it ran and its output](/articles/claude-code-opentelemetry-traces/2-tool-span-details.jpg)

Jaeger's UI is the right tool for one trace: why was *this* prompt slow, what did *this* command return. Every tool span has the same name in the timeline, though, so questions like "did a skill load?" mean clicking through spans. For those, and for anything across many sessions, use a script.

## Eight questions, each in a few lines

Jaeger has an HTTP API. The loader in the appendix (`traces.py`, about 50 lines) pulls recent spans from it and returns each one as a small dictionary. Every snippet below starts from this:

```python
import collections, re
from traces import load, tool_input

spans = load(hours=72)                                   # our last three days: 240 traces, 5,492 spans
tools = [s for s in spans if s["name"] == "tool"]
by_id = {s["id"]: s for s in spans}
```

The output under each snippet is from our own sessions over those three days. Part of that time went to a test where we ran Claude Code headless on a set of tasks, so your numbers will look different.

### 1. Where did the time go?

When a session feels slow, the first thing to find out is what kind of slow. Time can go to three places: the model thinking, a tool running, or a tool waiting for approval. Each has its own span type, and every span has a duration. The snippet adds up the seconds for each type.

```python
total = collections.Counter()
for s in spans:
    if s["name"] in ("llm_request", "tool.execution", "tool.blocked_on_user"):
        total[s["name"]] += s["ms"] / 1000
for name, sec in total.most_common():
    print(f"{name:22} {sec:6.0f} s")
```

```
llm_request              6911 s
tool.execution           2355 s
tool.blocked_on_user      694 s
```

The model took about three times as long as all the tools together. A slow session is usually many model calls, not a slow tool. The tool time that does add up comes from commands that start a server or wait on a remote service.

### 2. How long was Claude waiting for me?

Every tool call has a `blocked_on_user` span. It measures the time between Claude asking to use the tool and getting a decision. For calls your settings already allow, that's close to zero. For calls that need you, it's however long you took to click. The snippet groups that time by tool, using each span's parent to find the tool's name.

```python
waits = collections.Counter()
for s in spans:
    if s["name"] == "tool.blocked_on_user" and s["parent"] in by_id:
        waits[by_id[s["parent"]]["a"].get("tool_name")] += s["ms"] / 1000
for tool, sec in waits.most_common(3):
    print(f"{tool:8} {sec:5.0f} s")
```

```
Bash       401 s
Edit       137 s
Write       63 s
```

That's 694 seconds waiting on permission decisions, more than half of it for Bash. The `source` attribute says who decided: in ours, 1,234 calls were accepted by settings and 64 by a person. Those 64 are the ones to look at. If the same safe command keeps waiting for you, add it to your permissions allowlist.

### 3. What did it run in Bash, and did it reach the web?

Bash can do almost anything, so the tool's name alone tells you little. The command itself is recorded on the span. The snippet collects every command and flags the ones that reach the internet: a `curl` or `wget` to an address that isn't local, or a lookup on a package registry. Change the pattern to match the commands you care about.

```python
bash = [tool_input(s).get("command", "") for s in tools if s["a"].get("tool_name") == "Bash"]
web = [c for c in bash if re.search(
    r"(curl|wget)\s[^|;&]*https?://(?!localhost|127\.0\.0\.1)|pip index|npm view", c)]
print(len(bash), "Bash commands,", len(web), "reach the web")
```

```
581 Bash commands, 21 reach the web
```

Grouped by where they went: `pypi.org` 6, `pip index` 5, `npm view` 4, `restcountries.com` 3, `api.worldbank.org` 2, `github.com` 1.

This is the finding from the top of the article. In the same three days there were 2 WebSearch or WebFetch calls and 21 lookups from Bash, so it's worth reading the commands as well as counting the web tools. Fifteen were checking package versions. Our skill mentioned a version number without saying whether to trust it, so we added a line and those lookups stopped. The five calls to the two public APIs were Claude testing an API that its own code calls, which is exactly right. The pattern finds the commands; you still have to read them.

### 4. Did the right skill load?

A skill only helps if it loads. Claude decides that from the skill's description, so if the description doesn't match the way you phrase a request, the skill is skipped and nothing tells you. Loading a skill is a tool call named `Skill`. The snippet counts those calls and which skill each one loaded, then how many prompts had one.

```python
loads = [s for s in tools if s["a"].get("tool_name") == "Skill"]
print(collections.Counter(s["a"].get("skill_name") for s in loads))

prompts = [s for s in spans if s["name"] == "interaction"]
with_skill = {s["trace"] for s in loads}
print(len(prompts), "prompts,", sum(p["trace"] in with_skill for p in prompts), "loaded a skill")
```

```
Counter({'agnt5': 40})
183 prompts, 40 loaded a skill
```

A skill load is either in the trace or it isn't, which makes it easy to test. Send a prompt that should use the skill and check for the span. We did, with a small change request inside one of our projects, and the span wasn't there: the skill's description didn't match the request. We widened the description, sent the same prompt, and the span appeared.

Test the other direction too. Send prompts that have nothing to do with the skill and confirm it stays out.

### 5. Did my MCP tools work?

MCP tools are small services, and like any service they can be slow or fail. When one fails, Claude may work around it, so you might not notice. Each call's `execution` span has a duration and a `success` flag. The snippet builds one row per tool: how many calls, how many failed, and the average time.

```python
stats = collections.defaultdict(lambda: [0, 0, 0.0])        # calls, failed, total ms
for s in spans:
    if s["name"] == "tool.execution" and s["parent"] in by_id:
        name = by_id[s["parent"]]["a"].get("tool_name", "")
        if name.startswith("mcp__"):
            st = stats[name]
            st[0] += 1
            st[1] += str(s["a"].get("success")).lower() == "false"
            st[2] += s["ms"]
for name, (n, failed, ms) in sorted(stats.items(), key=lambda x: -x[1][0])[:4]:
    print(f"{name:40} {n:3} calls  {failed} failed  {ms / n:5.0f} ms avg")
```

```
mcp__claude_ai_AGNT5__get_run_events      87 calls  2 failed    975 ms avg
mcp__claude-in-chrome__computer           48 calls  0 failed    521 ms avg
mcp__outline__update_document             46 calls  4 failed   1272 ms avg
mcp__claude_ai_AGNT5__list_projects       27 calls  0 failed    740 ms avg
```

One line per tool tells you which is used most, which is slow and which fails. Here, one document tool failed 4 times in 46 calls. The `error` attribute on the failed spans says why.

### 6. Did it go in circles?

A common worry about agents is that they get stuck: they run a command, get the same result and try it again. In a trace, that shows up as two tool spans with the same tool and exactly the same input under one prompt. The snippet counts them.

```python
calls = collections.Counter((s["trace"], s["a"].get("tool_name"), s["a"].get("tool_input"))
                            for s in tools if s["a"].get("tool_input"))
print(sum(c - 1 for c in calls.values() if c > 1), "repeated calls in", len(prompts), "prompts")
```

```
6 repeated calls in 183 prompts
```

A loop shows up as the same tool with the same input, more than once, inside one prompt. We expected to find loops and found almost none. The six were things like taking a second screenshot or re-reading a document after editing it.

### 7. Did it test its work?

An agent can write code and say "done" without running it. The final answer may not make that clear, but the trace records every tool call in order, so you can see what happened after the last file change. The snippet finds the prompts where Claude edited or wrote a file, and counts the ones with no Bash command afterwards.

```python
edits = untested = 0
for p in prompts:
    names = [s["a"].get("tool_name") for s in tools if s["trace"] == p["trace"]]
    if "Edit" in names or "Write" in names:
        edits += 1
        last_edit = max(i for i, n in enumerate(names) if n in ("Edit", "Write"))
        untested += "Bash" not in names[last_edit + 1:]
print(edits, "prompts changed files;", untested, "ran nothing afterwards")
```

```
75 prompts changed files; 27 ran nothing afterwards
```

This is a rough signal, not a verdict. Many of our 27 were edits to documents, where there's nothing to run. But it gives you a short list to read, and it's how we noticed a real pattern: on build tasks, traces ended with a file write and a summary, and no command ran the project. We added one line to our skill, "a change is done only after it runs", and those traces now end with a run.

### 8. What runs that I never asked for?

Not every model call answers your prompt. Claude Code also makes calls of its own, and they use tokens too. Each model span has a `query_source` attribute that says why the call was made. The snippet counts the calls by that reason.

```python
print(collections.Counter(s["a"].get("query_source") for s in spans if s["name"] == "llm_request").most_common(3))
```

```
[('sdk', 1008), (None, 145), ('generate_session_title', 52)]
```

`query_source` says why each model call was made. Most are the calls that answer your prompts. The rest are background calls that Claude Code makes on its own, such as writing a session title, mostly on a smaller model. One of our background calls produced 5 output tokens and read 85,000 cached tokens. They're worth knowing about when you add up token use.

## Compare runs of the same task

To check whether a change helped, run the same prompt before and after and label each run. Any `OTEL_RESOURCE_ATTRIBUTES` you set are attached to that session's spans:

```bash
OTEL_RESOURCE_ATTRIBUTES="task=fix-login,run=2" claude -p "Fix the failing login test"
```

`claude -p` runs one prompt without the interactive UI, so it's easy to script. Your script can then pick out exactly those spans and ask the questions above of each run.

## What to change once you can see

| What the traces show | What to change |
|----------------------|----------------|
| Long permission waits on safe commands | Add them to your permissions allowlist |
| Claude exploring or guessing in a repo | Add the missing guidance to `CLAUDE.md` |
| Lookups for things you've already documented | Say in your instructions what to trust |
| A skill that doesn't load, or loads on unrelated prompts | Rewrite its description |
| Work that's never run | Say when a task counts as done |
| An MCP tool that fails often or returns huge results | Fix the tool, or trim its output |

Then send the same prompts again and compare. That's the whole loop: look, change one thing, look again.

## Privacy and limits

- **Traces contain personal data.** Every span carries your email and account IDs. With the content settings on they also hold your prompts, commands and tool output, and with detailed tracing the system prompts too. That's fine on a local Jaeger. Think carefully before you send them to a shared or hosted backend.
- **Tracing is in beta.** Span names and attributes may change between Claude Code versions.
- **If Jaeger isn't running, traces are dropped silently.** Set Docker to start when you sign in.
- **Local sessions give the full picture; cloud sessions don't yet.** In sessions on claude.ai/code we received the background model calls but not the prompt and tool spans.
- **Traces show what happened, not what should have.** To know whether a skill *should* have loaded, you need your own list of prompts with the expected answer.
- **MCP connection events** (a server connected or failed to connect) are log events, not spans, so Jaeger doesn't show them.

## Appendix: Jaeger config

```yaml
service:
  extensions: [jaeger_storage, jaeger_query, healthcheckv2]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger_storage_exporter]
extensions:
  healthcheckv2:
    use_v2: true
    http:
      endpoint: 0.0.0.0:13133
  jaeger_query:
    storage:
      traces: claude_store
    http:
      endpoint: 0.0.0.0:16686
  jaeger_storage:
    backends:
      claude_store:
        badger:
          directories:
            keys: /badger/keys
            values: /badger/values
          ephemeral: false
          ttl:
            spans: 720h
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch:
exporters:
  jaeger_storage_exporter:
    trace_storage: claude_store
```

## Appendix: the loader

Save as `traces.py` next to your scripts.

```python
"""Load recent Claude Code spans from a local Jaeger.

Each span is a dict with: name, trace, id, parent, ms (duration) and a (its attributes).
"""
import datetime as dt, json, urllib.error, urllib.parse, urllib.request

JAEGER = "http://localhost:16686"


def load(hours=24):
    now = dt.datetime.now(dt.timezone.utc)
    q = urllib.parse.urlencode({
        "query.service_name": "claude-code",
        "query.start_time_min": (now - dt.timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "query.start_time_max": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "query.num_traces": 5000,
    })
    try:
        with urllib.request.urlopen(f"{JAEGER}/api/v3/traces?{q}") as r:
            data = json.load(r)
    except urllib.error.HTTPError:          # Jaeger answers 404 when the window has no traces
        return []
    spans = {}
    for rs in data.get("result", {}).get("resourceSpans", []):
        for ss in rs.get("scopeSpans", []):
            for s in ss.get("spans", []):
                a = {x["key"]: next(iter(x["value"].values()), None) for x in s.get("attributes", []) if x.get("value")}
                spans[s["spanId"]] = {      # the same span can arrive twice; keep one
                    "name": s["name"].removeprefix("claude_code."),
                    "trace": s["traceId"], "id": s["spanId"], "parent": s.get("parentSpanId"),
                    "start": int(s["startTimeUnixNano"]),
                    "ms": (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) / 1e6,
                    "a": a,
                }
    return sorted(spans.values(), key=lambda s: s["start"])


def tool_input(span):
    """A tool span's input as a dict (Bash: {"command": ...}, Read: {"file_path": ...})."""
    a = span["a"]
    raw = a.get("tool_input") or ""
    body = raw.split("\n", 1)[1] if raw.startswith("[TOOL INPUT") and "\n" in raw else raw
    try:
        return json.loads(body)
    except ValueError:              # no detailed tracing: only the command or the file path is recorded
        return {k: v for k, v in (("command", a.get("full_command")), ("file_path", a.get("file_path"))) if v}
```

## Sources

- [Claude Code: Monitoring usage](https://code.claude.com/docs/en/monitoring-usage)
- [Agent SDK: Observability](https://code.claude.com/docs/en/agent-sdk/observability)
- [Jaeger](https://www.jaegertracing.io/)

## Frequently asked questions

### How do you turn on OpenTelemetry tracing in Claude Code?

Set CLAUDE_CODE_ENABLE_TELEMETRY and CLAUDE_CODE_ENHANCED_TELEMETRY_BETA to 1, set OTEL_TRACES_EXPORTER to otlp, and point OTEL_EXPORTER_OTLP_TRACES_ENDPOINT at an OTLP collector such as a local Jaeger. Put them in the env block of your user settings, ~/.claude/settings.json, then restart Claude Code. Tracing is in beta, so span names and attributes may change between versions.

### Can a repository's .claude/settings.json choose where Claude Code sends traces?

No. When we set the trace endpoints in a repository's .claude/settings.json, the traces still went to the endpoint in our user settings. Set them in ~/.claude/settings.json.

### Why are no Claude Code traces showing up in Jaeger?

If Jaeger isn't running when Claude Code exports, the traces are dropped without an error. Make sure the Jaeger container is up (set Docker to start when you sign in), restart Claude Code after changing settings, and pick the service claude-code in Jaeger.

### Do Claude Code traces contain personal data?

Yes. Every span carries your email and account IDs. With the content settings on, spans also hold your prompts, commands and tool output, and detailed tracing adds the system prompt. Keep them on a local backend, and think carefully before sending them to a shared or hosted one.
