Skip to main content

Monitoring Claude Code usage with the Aspire Dashboard

Note: this is a follow-up to my post on monitoring GitHub Copilot Chat with the Aspire Dashboard. Same idea, different agent.

Some developers on the team have switched (part-time) from Copilot Chat to Claude Code for their agentic work. Same question came up again: how much are we actually using it, what does it cost, and where does the time go in a session? Claude Code has its own OTel support, separate from Copilot's, so it needs its own setup — but it plugs into the exact same Aspire Dashboard we already had running.

What Claude Code exports

Claude Code emits three kinds of OTel signals:

  • Metrics — session counts, token usage, cost, lines of code changed, active time
  • Events — one event per prompt, per API call, per tool result, per permission decision
  • Traces (beta) — a claude_code.interaction span per prompt, with claude_code.llm_request and claude_code.tool spans nested underneath

Remark: traces are still beta. Metrics and events are the stable path and honestly cover most of what you want for a usage dashboard — cost, tokens, sessions, tool acceptance rate. Only reach for traces if you specifically want to see the timeline of one interaction, tool call by tool call.

Reusing the Aspire Dashboard

If you've still got the Aspire Dashboard container running from the Copilot setup, you don't need a second one. Claude Code and Copilot Chat can both point at the same OTLP endpoint — the dashboard separates them by service.name automatically.

If you don't have it running, use the Aspire CLI:

aspire dashboard run

Or use the available Docker image:

docker run --rm -d -p 18888:18888 -p 4318:18890 --name aspire-dashboard \
  mcr.microsoft.com/dotnet/aspire-dashboard:latest

Open http://localhost:18888 like before.

Enabling telemetry in Claude Code

Claude Code is configured through environment variables, not VS Code settings, since it also runs standalone in a terminal. Minimal setup for metrics and events over OTLP/HTTP, pointed at the same Aspire container:

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

claude

Run a session, ask it to do something, exit. Give it a few seconds (export intervals default to 60s for metrics and 5s for logs), so don't panic if the dashboard is empty immediately.

Tip: to verify without waiting on a real backend, swap OTEL_METRICS_EXPORTER=console first and watch the numbers print straight to your terminal. Once you see them, switch back to otlp.

Turning on traces too

Metrics and events don't need anything beyond what's above. If you also want the span tree — one claude_code.interaction per prompt, with the LLM calls and tool executions nested underneath — add the beta flag and a traces exporter:

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

claude

Back in the dashboard's Traces view, filter by service and you'll see claude_code.interaction as the root span, with claude_code.llm_request and claude_code.tool as children.  The same shape as the Copilot invoke_agent tree from last time, just under Anthropic's own naming.

Remark: by default, prompt text, tool arguments and tool output are all redacted in both events and traces. If you want the full content, set OTEL_LOG_USER_PROMPTS=1, OTEL_LOG_TOOL_DETAILS=1 and OTEL_LOG_TOOL_CONTENT=1.

What you get for free

Once the data is flowing, a few things are immediately useful without building any dashboard:

  • claude_code.cost.usage and claude_code.token.usage: actual spend and token counts per session, broken down by model
  • claude_code.code_edit_tool.decision: how often edits get accepted vs rejected, which is a decent proxy for "is this actually useful to people"
  • claude_code.active_time.total: real active time, not wall-clock time, so it isn't inflated by someone leaving a session open in a background tab

Rolling it out to a team

Everything above is developer-set environment variables, which is fine for trying it out. For a team rollout, the same keys go into Claude Code's managed settings file instead, so telemetry flows to an approved collector without every developer configuring it themselves.

That's it. Same dashboard, second agent, no new tooling.

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

The role of ActivitySource in OpenTelemetry for .NET

While doing some pair programming to integrate OpenTelemetry tracing to a .NET application, we had a discussion on how to use the ActivitySource . It looks simple. You new one up, give it a name, start an activity, done. The discussion started when we added a second ActivitySource with the exact same name in a different class. This made us wonder: "Are we duplicating traces now? Is this a memory leak? Do we need a singleton?" So we decided to dig deeper. This post is what we learned… What ActivitySource actually is ActivitySource is part of System.Diagnostics , not part of the OpenTelemetry NuGet packages. Microsoft built tracing primitives directly into the BCL, and OpenTelemetry's .NET SDK simply listens to them. This is why you can add distributed tracing to a library without taking a dependency on OpenTelemetry at all. An ActivitySource is a factory for Activity objects, and an Activity is .NET's name for what OpenTelemetry calls a span.(don’t ask m...