DEV Community

Li Zhuojun
Li Zhuojun

Posted on

An append-only audit log caught two accounting bugs in a 216-star usage tracker

Last month I wrote about auditing 34 days of multi-model Claude Code usage and finding that a single missing model line was half my overspend. The core of that piece was a boring engineering decision: ingest every assistant message from Claude Code's session files into an append-only log keyed by message.id, and never let the source rewrite history.

This is the follow-up I didn't plan to write. The same log just caught two accounting bugs in splitrail, a 216-star Rust usage tracker for agentic CLIs — and within twelve days of the first report, both were fixed in an official release: one through my PR, one through a maintainer-authored patch that adopted the report's analysis. Here's the play-by-play, with the numbers.

Bug 1: your history quietly rewrites itself

Claude Code rewrites session JSONL files in place when you resume or compact a conversation. Between two scans of the same file, five of my assistant messages vanished — usage that was already incurred, gone from the record. Any tracker that recomputes totals from live files inherits this drift: yesterday's numbers change while you sleep.

I filed it as #200. The maintainer confirmed the mechanism the same day — splitrail re-read live files, so messages removed by resume/compaction disappeared from historical totals — and shipped a fix in 3.6.0: a local SQLite history store that persists normalized usage and merges it with current session data before deduplication. Fast, clean work.

One wrinkle: he suggested validating by comparing 3.5.9 vs 3.6.0 totals, expecting "3.6.0 should be higher." That's subtly wrong, and the distinction matters for anyone testing this class of fix: on a cold start the two versions are equal — the history store has nothing to restore yet. The divergence only appears across drift events. So I built the test to create one.

The validation protocol

Everything ran against a frozen snapshot (APFS clone) of ~/.claude/projects, so both versions scanned identical bytes, with every invocation under an isolated $HOME — own config, own history store, no upload path. Then:

# assertion result
A cold-start parity: fresh 3.5.9 == fresh 3.6.0 on identical input ✅ identical
B 3.5.9 across a simulated resume/compact rewrite ✅ drops by exactly the removed usage
C 3.6.0 across the same rewrite ✅ totals unchanged — history store restores
D 3.6.0 restart stability, 3 consecutive runs ✅ byte-identical output

The simulated rewrite removes the last five assistant message-groups from the largest transcript — the same shape as the original drift event. The whole thing is now a self-contained regression fixture (PR #208, since merged): it fails red on 3.5.9 and passes green on 3.6.0, which is exactly what you want a regression test to do.

The part I care most about: on the transcripts splitrail scans, 3.6.0 agreed with my append-only log token-exact — 18,548,947 output tokens on both sides across 13.5k messages. Two independent implementations, different languages, different dedup strategies, same number to the digit. (The tiny message-count delta was zero-usage api-error records splitrail intentionally skips.) When two systems reconcile exactly, every remaining discrepancy is a finding, not noise.

Bug 2: the discrepancy that was a finding

Because the log records where every message came from, I could classify all of them against the live tree:

class files messages output tokens
live, main transcript (what splitrail scans) 76 13,704 18,548,947
live, subagents/** transcript 1,423 16,160 13,738,324
vanished (file exists, message.id gone) 0 0 0
deleted (session file gone) 874 11,821 14,238,006

Row two is the second bug. Claude Code writes subagent transcripts (Task tool: Explore, general-purpose, custom agents) under projects/<slug>/<sessionId>/subagents/, at directory depth ≥ 4. Splitrail's discovery hard-caps at depth 2 in three places (WalkDir…min_depth(2).max_depth(2), a components() == 2 path check, and the glob pattern). Those files are structurally invisible — 54% of my live messages, and roughly a third of the dollars, never entered any total.

The model mix makes the blind spot vivid: splitrail saw 22 of my 5,059 Sonnet messages and 0 of 1,566 Haiku messages. Those models run almost exclusively inside subagents. If you delegate heavily to cheap models — which is exactly what cost-conscious agent users do — your tracker undercounts the most, on the workflows you optimized hardest.

Filed as #207 with the layout, a two-line repro, and a suggested fix. Two days later it was closed — and not by my patch. The maintainer wrote the fix himself: #209 "Include Claude Code subagent transcripts" (+428/−100 across seven files), whose description restates the issue's analysis and adopts its central caution — simply lifting the depth cap isn't enough; subagent records have to flow through the same local_hash dedup semantics as main transcripts, or you trade an undercount for a double-count. Both fixes shipped together in splitrail 3.6.1, which hit Homebrew within a day. On my corpus, the newly visible class is row two of the table above: 16,160 messages and 13.7M output tokens finally entering the totals. A fresh 3.6.1-vs-log reconciliation of the live tree is the obvious next check — part 3 starts there.

What I'd generalize

Live mutable files are not an audit trail. If your source can rewrite history, recomputation is not accounting. Append-only ingest with a stable per-record identity (message.id, last-write-wins on partials) is cheap insurance — mine is a few hundred lines and a SQLite file.

Reconcile token-exact or you know nothing. "Close enough" totals hide entire bug classes. It was exact agreement on the scanned subset that turned the remaining gap into two nameable, fixable defects instead of a shrug.

Decompose the gap before you blame anyone. "Your numbers are lower than mine" is an accusation; "the gap is exactly deleted-files + an unscanned directory class, here's the table" is a bug report a maintainer can act on in hours. Both turnarounds here — #200→#204 in eight days, #207→#209 in two — happened because the mechanism arrived with the report.

Frozen snapshot + isolated $HOME is the whole trick for tracker A/B. Clone the data, pin HOME (and XDG_STATE_HOME/XDG_DATA_HOME — on Linux, state dirs honor XDG and will escape your sandbox; CodeRabbit's review caught that one in my own fixture, fair is fair), and two binaries scanning identical bytes become a controlled experiment.

Timeline

date (2026) event
Jul 11 #200 filed — resume/compact rewrites session JSONL in place
Jul 19 #204 merged; 3.6.0 ships the SQLite history store
Jul 20 4-assertion A/B validation on a frozen corpus; token-exact reconciliation vs the audit log
Jul 21 #207 filed (subagent blind spot); PR #208 opened; CodeRabbit review round addressed same day
Jul 22 PR #208 merged
Jul 22–23 Maintainer authors and merges #209; #207 closed; 3.6.1 released, Homebrew bumped

Twelve days, two bugs, zero arguments about whose numbers were right — the log settled every question before it became a debate.

The layer underneath

All of this ran on TraceGuard's routing_audit module (v1.1.0, "audit evidence layer") — the append-only, message.id-keyed ingest of Claude Code transcripts into a SQLite trace store that part 1 was built on. Stable totals are the substrate; the point of the layer above is stated-vs-revealed routing analysis, priced per decision — which model you said you'd route to, which one actually ran, and what the difference cost. That's part 3, and my totals finally sit still long enough to write it. (Apparently not a niche itch, either — the splitrail maintainer starred TraceGuard during this exchange and said he'd be interested in the audit part.)

pip install traceguard — Apache-2.0. The regression fixture is ~600 lines of stdlib Python + bash, merged in PR #208; the subagent fix shipped in splitrail 3.6.1.

Top comments (0)