This page is a deep dive into how Claude Code Session Analytics counts tokens and cost. Each number is easy to get wrong, and I got most of them wrong first. Here is what the data looks like, what went wrong, and how the tool counts now.
The transcript grain
My first model was simple: one .jsonl file is one session. The first ingest found 103 of 250 transcripts.
The missing files were sub-agents. Claude Code writes each sub-agent to its own file under <project>/<session_id>/subagents/agent-<id>.jsonl. These files repeat the parent’s sessionId, but they contain a different set of request IDs: zero overlap. They are real, additional API calls.
So the unit that maps one-to-one onto a report is the transcript, not the session. The constructed schema has a transcripts table between sessions and api_calls, and a session is a rollup over its transcripts. After a little over one month of use, sub-agents were 49% of all billed input tokens.
The data model
The schema claude has one fact table and some metadata and audit tables:
api_calls— one Claude API call (one request ID group): tokens, model, agent, agent type, timing.total_input_tokensis a generated column: input + cache write + cache read.projects,sessions,transcripts— the hierarchy. A transcript has its size, hash, time window, models, CLI versions and git branches.model_pricing— one price point per model and date.ingest_runs,csv_files,parquet_exports,migrations— what was loaded, when, and from which file.
The views do the rest. Each dashboard panel and each report reads a view, not the raw tables, so the counting rules live in one place.
The sub-agent calls that were missing
In October I checked the integrity rule again: the loaded rows for each transcript must equal the call count from the transcript itself. It failed. Only 3 rows were linked to 511 sub-agent transcripts, against 8,701 expected calls. The sub-agent calls were in the database, but they were credited to the parent session.
The fix stamps each call with its own sub-agent transcript, and a relink step fixes the history from the database alone. After the relink, 8,684 calls moved to their sub-agent transcripts.
A second defect was hidden behind the first. A resumed or forked session can contain sub-agent records whose file lives under another session. Those calls stayed on the main transcript, and the same calls were also loaded from the sub-agent file: 46 duplicate rows, approximately 2.85 million input tokens counted two times. The relink now matches sub-agent transcripts across sessions and removes the duplicates.
With correct links, the sub-agent share is 23.9% of input tokens all time (438 million of 1.83 billion, 8,741 calls). In August, on a much smaller data set, it was 49%: as my usage grew, the share halved.
Why /stats shows a bigger number
One API call can write more than one record to the transcript: one record for each content block of the answer, under the same request ID. The input, cache-write and cache-read counts are the same in each record. The output count is different.
The tool deduplicates the group: input, cache write and cache read once, and the maximum output. Claude Code’s own /stats does not group the records. It adds the tokens of every record.
To compare, the tool keeps three extra columns that reproduce the /stats arithmetic: the number of records in the group, the raw output sum, and a generated duplicated_total_tokens. They do not change the deduplicated columns. For my last 30 days (dashboard defaults):
| Tracked (deduplicated) | /stats arithmetic | |
|---|---|---|
| Tokens | 468 M | 956 M |
| Estimated API cost | $194.24 | $407.98 |
The /stats number is approximately two times the real one.
The cost model
The cost is an estimate at Claude API list prices. The prices live in data/reference/model_pricing.csv that is updated as new models are deployed. A price change is a new row, never an edit, so an old call keeps the price that was valid on its day.
acp pricing refreshcompares a snapshot of the pricing page with the CSV and appends only the changed rows.acp pricing sync-dbloads the CSV intoclaude.model_pricing.- The cost views take the newest price with
effective_from <= called_at. A model with no price givesNULL, never0— so an unknown model can not make the total look smaller. - Long-context tiers. Some models like Haiku 5.5 have a higher rate above a prompt-length threshold. When a call’s input (input + cache write + cache read) is above the threshold, the full request is priced at the long rates.
All time, my usage would cost $852.34 at list prices, and $1,560.19 with the /stats arithmetic. The 96.74% cache-read share is the main reason it is not much higher: cache reads cost a fraction of the normal input price.
Sessions that start sessions
Some of my sessions hand work to a new session. The handoff prompt carries an Origin session_id: line. The loader stores that ID as the claimed parent, and the view v_session_lineage resolves the root and the depth of each chain, with a guard against cycles.
The Session lineage and Multi-agent dashboards show these chains.
How the numbers are checked
- Loaded rows per transcript = the call count in the transcript.
- One CSV for each transcript with calls, and no orphan calls.
- A Parquet round-trip (export,
COPY FROM, sum again) matches exactly. - Each sub-agent transcript is owned by one path, by file hash, so a symlinked file is not loaded two times.
Links
Project page: Claude Code Session Analytics
Code: GitHub
Related: Is your Claude Code context actually optimised?