{"slug": "how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude", "title": "How do you analyze your coding sessions with OpenTelemetry traces from Claude Code?", "summary": "A developer on the AGNT5 team enabled Claude Code's OpenTelemetry tracing, backed by a self-hosted Jaeger instance, to inspect what happens between prompts during coding sessions. The traces revealed Claude was repeatedly querying package versions from the shell because their skill file cited a version number without saying whether to trust it; adding one line instructing Claude to use the skill's versions eliminated the lookups. The writeup documents the Jaeger and settings.json setup, the span and attribute schema, and eight Python-based questions teams can ask of their own session traces.", "body_md": "We use Claude Code every day to build [AGNT5](https://agnt5.com), a platform for running AI agents and workflows. 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. (A skill is a folder of instructions that Claude Code loads when a task needs it. Ours teaches it how to work with AGNT5.)\n\nSo 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:\n\n```\ntool Bash   62ms   ls -la; pip index versions agnt5 | head -2; which agnt5 uv\ntool Bash   51ms   uv pip index versions agnt5 | head -1 || curl -s https://pypi.org/pypi/agnt5/json | …\ntool Bash  292ms   curl -s https://pypi.org/pypi/agnt5/json | …; agnt5 --version\n```\n\nThat 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.\n\nThis 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.\n\nEach prompt you send becomes one trace. It's a tree of spans:\n\n```\nclaude_code.interaction            ← your prompt → Claude's answer\n├── claude_code.llm_request        ← each model call\n└── claude_code.tool               ← each tool call: Bash, Read, MCP, Skill…\n    ├── tool.blocked_on_user       ← time waiting for a permission decision\n    └── tool.execution             ← time actually running, success true/false\n```\n\nEach span carries attributes. These are the ones this article uses:\n\n| Span | Attributes | \n|---|---|\n| `interaction` | `user_prompt` | \n| `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` | \n| `tool` | `tool_name` ,`tool_input` (the command, file path or arguments),`skill_name` | \n| `tool.blocked_on_user` | `decision` (accept or reject),`source` (your settings or you) | \n| `tool.execution` | `success` ,`error` | \n\nMCP 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.\n\nYou need Docker and Claude Code. Nothing leaves your machine.\n\n**1. Start Jaeger.** It stores the traces and gives you a UI at [http://localhost:16686](http://localhost:16686). Save the config from the appendix as `jaeger-config.yaml`, then run:\n\n```\ndocker volume create jaeger-badger\ndocker run -d --name jaeger --restart unless-stopped --user 0 \\\n  -p 16686:16686 -p 4317:4317 -p 4318:4318 \\\n  -v jaeger-badger:/badger \\\n  -v \"$PWD/jaeger-config.yaml:/etc/jaeger/config.yaml:ro\" \\\n  jaegertracing/jaeger:2.23.0 --config /etc/jaeger/config.yaml\n```\n\nThe config keeps traces on disk for 30 days. Jaeger's default image keeps them in memory and loses them on restart.\n\n**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.\n\n```\n{\n  \"env\": {\n    \"CLAUDE_CODE_ENABLE_TELEMETRY\": \"1\",\n    \"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA\": \"1\",\n    \"OTEL_TRACES_EXPORTER\": \"otlp\",\n    \"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL\": \"http/protobuf\",\n    \"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT\": \"http://localhost:4318/v1/traces\",\n    \"OTEL_LOG_TOOL_DETAILS\": \"1\",\n    \"OTEL_LOG_TOOL_CONTENT\": \"1\",\n    \"OTEL_LOG_USER_PROMPTS\": \"1\",\n    \"ENABLE_BETA_TRACING_DETAILED\": \"1\",\n    \"BETA_TRACING_ENDPOINT\": \"http://localhost:4318\"\n  }\n}\n```\n\n| Lines | What they do | \n|---|---|\n| First five | Turn on tracing and send it to Jaeger. On their own they give you timings and success or failure | \n| The three `OTEL_LOG_*` | Add content that is left out by default: commands and tool arguments, tool output, and your prompts | \n| 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 | \n\nWe ran the same prompt at each level to see what you get:\n\n| Settings on | Spans contain | \n|---|---|\n| First five | Timings, tool names, success or failure, token counts. The prompt is recorded as `<REDACTED>` | \n| First eight | Also the prompt text, the Bash command ( `full_command` ), file paths and tool output | \n| 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 | \n\nYour email and account IDs are on every span at every level.\n\nDetailed 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.\n\n**3. Send a prompt.** Restart Claude Code, send any prompt, then open Jaeger and pick the service `claude-code`.\n\nHere is one real prompt. It ran a Bash command, read a file and called an MCP server: 7.1 seconds, 16 spans.\n\n**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.\n\n**A tool call.** Click a tool span to see its attributes: the exact command and what it returned.\n\nJaeger'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.\n\nJaeger 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:\n\n``` python\nimport collections, re\nfrom traces import load, tool_input\n\nspans = load(hours=72)                                   # our last three days: 240 traces, 5,492 spans\ntools = [s for s in spans if s[\"name\"] == \"tool\"]\nby_id = {s[\"id\"]: s for s in spans}\n```\n\nThe 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.\n\nWhen 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.\n\n```\ntotal = collections.Counter()\nfor s in spans:\n    if s[\"name\"] in (\"llm_request\", \"tool.execution\", \"tool.blocked_on_user\"):\n        total[s[\"name\"]] += s[\"ms\"] / 1000\nfor name, sec in total.most_common():\n    print(f\"{name:22} {sec:6.0f} s\")\nllm_request              6911 s\ntool.execution           2355 s\ntool.blocked_on_user      694 s\n```\n\nThe 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.\n\nEvery 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.\n\n```\nwaits = collections.Counter()\nfor s in spans:\n    if s[\"name\"] == \"tool.blocked_on_user\" and s[\"parent\"] in by_id:\n        waits[by_id[s[\"parent\"]][\"a\"].get(\"tool_name\")] += s[\"ms\"] / 1000\nfor tool, sec in waits.most_common(3):\n    print(f\"{tool:8} {sec:5.0f} s\")\nBash       401 s\nEdit       137 s\nWrite       63 s\n```\n\nThat'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.\n\nBash 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.\n\n```\nbash = [tool_input(s).get(\"command\", \"\") for s in tools if s[\"a\"].get(\"tool_name\") == \"Bash\"]\nweb = [c for c in bash if re.search(\n    r\"(curl|wget)\\s[^|;&]*https?://(?!localhost|127\\.0\\.0\\.1)|pip index|npm view\", c)]\nprint(len(bash), \"Bash commands,\", len(web), \"reach the web\")\n581 Bash commands, 21 reach the web\n```\n\nGrouped by where they went: `pypi.org` 6, `pip index` 5, `npm view` 4, `restcountries.com` 3, `api.worldbank.org` 2, `github.com` 1.\n\nThis is where the finding from the top of the article came from. 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.\n\nA 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.\n\n```\nloads = [s for s in tools if s[\"a\"].get(\"tool_name\") == \"Skill\"]\nprint(collections.Counter(s[\"a\"].get(\"skill_name\") for s in loads))\n\nprompts = [s for s in spans if s[\"name\"] == \"interaction\"]\nwith_skill = {s[\"trace\"] for s in loads}\nprint(len(prompts), \"prompts,\", sum(p[\"trace\"] in with_skill for p in prompts), \"loaded a skill\")\nCounter({'agnt5': 40})\n183 prompts, 40 loaded a skill\n```\n\nA 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.\n\nTest the other direction too. Send prompts that have nothing to do with the skill and confirm it stays out.\n\nMCP 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.\n\n```\nstats = collections.defaultdict(lambda: [0, 0, 0.0])        # calls, failed, total ms\nfor s in spans:\n    if s[\"name\"] == \"tool.execution\" and s[\"parent\"] in by_id:\n        name = by_id[s[\"parent\"]][\"a\"].get(\"tool_name\", \"\")\n        if name.startswith(\"mcp__\"):\n            st = stats[name]\n            st[0] += 1\n            st[1] += str(s[\"a\"].get(\"success\")).lower() == \"false\"\n            st[2] += s[\"ms\"]\nfor name, (n, failed, ms) in sorted(stats.items(), key=lambda x: -x[1][0])[:4]:\n    print(f\"{name:40} {n:3} calls  {failed} failed  {ms / n:5.0f} ms avg\")\nmcp__claude_ai_AGNT5__get_run_events      87 calls  2 failed    975 ms avg\nmcp__claude-in-chrome__computer           48 calls  0 failed    521 ms avg\nmcp__outline__update_document             46 calls  4 failed   1272 ms avg\nmcp__claude_ai_AGNT5__list_projects       27 calls  0 failed    740 ms avg\n```\n\nOne 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.\n\nA 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.\n\n```\ncalls = collections.Counter((s[\"trace\"], s[\"a\"].get(\"tool_name\"), s[\"a\"].get(\"tool_input\"))\n                            for s in tools if s[\"a\"].get(\"tool_input\"))\nprint(sum(c - 1 for c in calls.values() if c > 1), \"repeated calls in\", len(prompts), \"prompts\")\n6 repeated calls in 183 prompts\n```\n\nA 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.\n\nAn 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.\n\n```\nedits = untested = 0\nfor p in prompts:\n    names = [s[\"a\"].get(\"tool_name\") for s in tools if s[\"trace\"] == p[\"trace\"]]\n    if \"Edit\" in names or \"Write\" in names:\n        edits += 1\n        last_edit = max(i for i, n in enumerate(names) if n in (\"Edit\", \"Write\"))\n        untested += \"Bash\" not in names[last_edit + 1:]\nprint(edits, \"prompts changed files;\", untested, \"ran nothing afterwards\")\n75 prompts changed files; 27 ran nothing afterwards\n```\n\nThis 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.\n\nNot 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.\n\n```\nprint(collections.Counter(s[\"a\"].get(\"query_source\") for s in spans if s[\"name\"] == \"llm_request\").most_common(3))\n[('sdk', 1008), (None, 145), ('generate_session_title', 52)]\n```\n\n`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.\n\nTo 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:\n\n```\nOTEL_RESOURCE_ATTRIBUTES=\"task=fix-login,run=2\" claude -p \"Fix the failing login test\"\n```\n\n`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.\n\n| What the traces show | What to change | \n|---|---|\n| Long permission waits on safe commands | Add them to your permissions allowlist | \n| Claude exploring or guessing in a repo | Add the missing guidance to `CLAUDE.md` | \n| Lookups for things you've already documented | Say in your instructions what to trust | \n| A skill that doesn't load, or loads on unrelated prompts | Rewrite its description | \n| Work that's never run | Say when a task counts as done | \n| An MCP tool that fails often or returns huge results | Fix the tool, or trim its output | \n\nThen send the same prompts again and compare. That's the whole loop: look, change one thing, look again.\n\n```\nservice:\n  extensions: [jaeger_storage, jaeger_query, healthcheckv2]\n  pipelines:\n    traces:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [jaeger_storage_exporter]\nextensions:\n  healthcheckv2:\n    use_v2: true\n    http:\n      endpoint: 0.0.0.0:13133\n  jaeger_query:\n    storage:\n      traces: claude_store\n    http:\n      endpoint: 0.0.0.0:16686\n  jaeger_storage:\n    backends:\n      claude_store:\n        badger:\n          directories:\n            keys: /badger/keys\n            values: /badger/values\n          ephemeral: false\n          ttl:\n            spans: 720h\nreceivers:\n  otlp:\n    protocols:\n      grpc:\n        endpoint: 0.0.0.0:4317\n      http:\n        endpoint: 0.0.0.0:4318\nprocessors:\n  batch:\nexporters:\n  jaeger_storage_exporter:\n    trace_storage: claude_store\n```\n\nSave as `traces.py` next to your scripts.\n\n```\n\"\"\"Load recent Claude Code spans from a local Jaeger.\n\nEach span is a dict with: name, trace, id, parent, ms (duration) and a (its attributes).\n\"\"\"\nimport datetime as dt, json, urllib.error, urllib.parse, urllib.request\n\nJAEGER = \"http://localhost:16686\"\n\ndef load(hours=24):\n    now = dt.datetime.now(dt.timezone.utc)\n    q = urllib.parse.urlencode({\n        \"query.service_name\": \"claude-code\",\n        \"query.start_time_min\": (now - dt.timedelta(hours=hours)).strftime(\"%Y-%m-%dT%H:%M:%SZ\"),\n        \"query.start_time_max\": now.strftime(\"%Y-%m-%dT%H:%M:%SZ\"),\n        \"query.num_traces\": 5000,\n    })\n    try:\n        with urllib.request.urlopen(f\"{JAEGER}/api/v3/traces?{q}\") as r:\n            data = json.load(r)\n    except urllib.error.HTTPError:          # Jaeger answers 404 when the window has no traces\n        return []\n    spans = {}\n    for rs in data.get(\"result\", {}).get(\"resourceSpans\", []):\n        for ss in rs.get(\"scopeSpans\", []):\n            for s in ss.get(\"spans\", []):\n                a = {x[\"key\"]: next(iter(x[\"value\"].values()), None) for x in s.get(\"attributes\", []) if x.get(\"value\")}\n                spans[s[\"spanId\"]] = {      # the same span can arrive twice; keep one\n                    \"name\": s[\"name\"].removeprefix(\"claude_code.\"),\n                    \"trace\": s[\"traceId\"], \"id\": s[\"spanId\"], \"parent\": s.get(\"parentSpanId\"),\n                    \"start\": int(s[\"startTimeUnixNano\"]),\n                    \"ms\": (int(s[\"endTimeUnixNano\"]) - int(s[\"startTimeUnixNano\"])) / 1e6,\n                    \"a\": a,\n                }\n    return sorted(spans.values(), key=lambda s: s[\"start\"])\n\ndef tool_input(span):\n    \"\"\"A tool span's input as a dict (Bash: {\"command\": ...}, Read: {\"file_path\": ...}).\"\"\"\n    a = span[\"a\"]\n    raw = a.get(\"tool_input\") or \"\"\n    body = raw.split(\"\\n\", 1)[1] if raw.startswith(\"[TOOL INPUT\") and \"\\n\" in raw else raw\n    try:\n        return json.loads(body)\n    except ValueError:              # no detailed tracing: only the command or the file path is recorded\n        return {k: v for k, v in ((\"command\", a.get(\"full_command\")), (\"file_path\", a.get(\"file_path\"))) if v}\n```\n\n*We're building [AGNT5](https://agnt5.com). The skill mentioned in this article is open source: [agnt5dev/skills](https://github.com/agnt5dev/skills).*", "url": "https://wpnews.pro/news/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude", "canonical_source": "https://dev.to/manojnaidu_yandrapu_c4276/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude-code-i08", "published_at": "2026-11-12 23:43:52+00:00", "updated_at": "2026-11-12 23:49:30.683037+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "mlops", "ai-infrastructure"], "entities": ["Claude Code", "AGNT5", "Anthropic", "Jaeger", "OpenTelemetry", "Docker"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude", "markdown": "https://wpnews.pro/news/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude.md", "text": "https://wpnews.pro/news/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude.txt", "jsonld": "https://wpnews.pro/news/how-do-you-analyze-your-coding-sessions-with-opentelemetry-traces-from-claude.jsonld"}}