Journal How-to
Claude Code cost per project: a practical guide to attribution
How to find out what Claude Code costs per project, per branch and per pull request using its built-in OpenTelemetry metrics, repository attributes and a small session hook.
8 min read
“How much did the new billing service cost us in AI?” sounds like a simple question. With Claude Code it is answerable — the data is already being produced on every developer’s machine — but it takes a little plumbing to get from raw counters to a number per project.
This guide covers the mechanics: what Claude Code emits, how to tie each dollar to a repository, how to add branch and ticket context, and the edge cases that will skew your numbers if you ignore them.
Step 1: turn on metrics (and only metrics)
Claude Code ships with OpenTelemetry support. Enabling it is a handful of environment variables, which can live in the user’s ~/.claude/settings.json or — better for teams — in managed settings:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "none",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://your-collector.example.com",
"OTEL_METRICS_INCLUDE_SESSION_ID": "true",
"OTEL_METRICS_INCLUDE_REPOSITORY": "true"
}
}
Note the explicit none for logs and traces. For cost attribution you only need metrics, and leaving the content-bearing exporters off means prompts and tool details never leave the machine.
One gotcha: a repository’s own .claude/settings.json cannot configure the OpenTelemetry exporter. This has to be set at user level or via managed settings.
Step 2: understand the cost metric
The metric you care about is claude_code.cost.usage, in USD. A few properties matter for aggregation:
- It’s a delta counter. By default each data point is the increase since the previous export, so you can simply sum them. No need to diff cumulative values.
- It’s an estimate. It’s computed from token usage and list prices. It will be close to, but not exactly, what the invoice says. If you need exact numbers for finance, reconcile monthly against your provider’s cost reporting API.
- On seat-based plans it isn’t cash. If you pay per seat, treat the number as the API-equivalent value of the usage. It’s still the right basis for comparing projects.
Alongside cost, claude_code.token.usage (split by type: input, output, cacheRead, cacheCreation) is useful for explaining why a project is expensive — a project with a huge cache-creation share often has enormous context files being re-read in every session.
Step 3: attribute each data point to a repository
From Claude Code v2.1.269, setting OTEL_METRICS_INCLUDE_REPOSITORY=true adds vcs.repository.url.full, vcs.repository.name and vcs.owner.name to each data point. That’s the single most important attribute for per-project reporting, and it’s resolved locally — no extra tooling required.
Normalise the URL before you use it as a key. These are all the same repository:
[email protected]:acme/checkout-api.git
https://github.com/acme/checkout-api
https://[email protected]/acme/checkout-api.git
A reasonable normalisation is host/owner/name, lower-cased, with credentials and the .git suffix removed. Then map repositories to projects in a small table. Most projects are a single repository; let a project own several when a product spans backend, frontend and infra.
Step 4: add branch and ticket with a hook
Repository tells you what was worked on. Branch tells you why. If your team names branches after tickets — KST-482-rate-limits — you can get cost per ticket for free.
Claude Code doesn’t put the branch on its metrics, but its hooks system can fill the gap. A SessionStart hook (and a CwdChanged hook, for sessions that move between directories) receives the session_id and working directory on stdin. The hook can ask git for the remote and branch, extract a ticket ID, and post a small context record:
{
"session_id": "6f1c2a9e-…",
"event": "SessionStart",
"repo_url": "https://github.com/acme/checkout-api.git",
"branch": "KST-482-rate-limits",
"ticket": "KST-482",
"at": "2026-10-17T09:41:12Z"
}
Three rules for writing that hook:
- Run it async with a short timeout. It must never slow down session start.
- Never send the working directory. Full paths leak usernames and folder structure. Send only what you derived from git.
- Strip credentials from the remote URL. HTTPS remotes sometimes embed tokens.
On the server, join metrics to context by session.id, taking the most recent context record at or before each data point. That handles a session that switches branches midway.
Step 5: cost per pull request
Once spend is attributed to a project and branch, cost per PR is a division: spend on branches that produced a merged PR, divided by the number of merged PRs, per project per month. claude_code.pull_request.count gives you a rough count without any GitHub integration; joining on merged PRs from your Git host is more accurate.
Use it as a trend line, not a target. A stable $60 per PR on a gnarly legacy service is fine. A jump from $25 to $90 on a simple service in one week deserves a look — often it’s a single session stuck in a loop.
Edge cases that will bite you
| Situation | What happens | What to do |
|---|---|---|
| Agent older than v2.1.269 | No vcs.* attributes on metrics |
Fall back to the hook’s repo_url, joined on session |
| Session outside any repo | Nothing to attribute to | Show it as Unattributed — don’t hide it |
| One session, several repos | Attribution changes mid-session | Repo is per data point; CwdChanged updates branch context |
| Parallel sessions, same repo | Two session IDs, same project | Nothing special; sum them |
| Worktrees | Same remote, different paths | Remote URL normalisation makes them one project |
| Exporter retries | Same batch sent twice | De-duplicate on session, metric, attributes and time window |
Or skip the plumbing
All of the above is roughly a week of work to build properly, plus a dashboard. If you’d rather not, SpendByTeam does exactly this — one npx command per developer or one managed settings file — and shows you every byte it receives.
Either way, the data is already there. The only question is whether you’re reading it.