Skip to content

fix(telemetry): one call, one key; one repository, one id - #1049

Draft
blafourcade wants to merge 14 commits into
nextfrom
fix/one-call-one-key-one-repository-one-id
Draft

blafourcade wants to merge 14 commits into
nextfrom
fix/one-call-one-key-one-repository-one-id

Conversation

@blafourcade

@blafourcade blafourcade commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

🎯 What & why

One repository could get several ids, and one call several keys, so the report split repositories and counted some calls more than once. Two holes from the R1 telemetry audit:

  • M7. Owner and repo kept their case on every host, and an ssh:// or git:// host kept its case too (WHATWG URL folds only special schemes). git@github.com:Example/X.git and https://github.com/example/x gave two ids.
  • M10. A call with no requestId was keyed message.id:sessionId:timestamp. Its copies carry different timestamps, so they were never folded.

Data already stored by released CLIs is migrated (maintainer decision: the measurement is a beta).

🛠️ How it works

  • Identity (repository-identity.ts:58):
    • The host is case-folded on every form.
    • ssh.github.com is read as github.com (GitHub: SSH over the HTTPS port).
    • Owner and repo are case-folded on github.com only, which its REST docs say are "not case sensitive".
    • Every other host, GitHub Enterprise included, keeps their case. A split is safer than merging two repositories.
  • Key. A call with no requestId is keyed message.id:sessionId. The Messages API documents id as a "Unique object identifier". Keys that carry a requestId are unchanged.
  • Migration. A stored id is a sha-256, so only a live clone's origin can say which id an earlier version gave it.
    • StoredUsageMigration locates each root in roots.json once. It writes ledger/repository-ids.json, a table from each earlier id to the id it has now.
    • It then rewrites roots.json and every ledger partition, re-keyed, re-ided and folded, and marks the table complete last.
    • It runs first in every ingest (ingest-usage-use-case.ts:97), so in every report and in the plugin's hook, under the ledger lock. Every write goes through the existing write-then-rename.
  • Read-time rules. Every reader of the ledger (usage-ledger-adapter.ts:93), roots.json and branches.jsonl applies the key rule and the table.
    • Figures stay right in a half-migrated state, and when an older CLI writes again afterwards.
    • branches.jsonl is never rewritten, so it stays append-only.
  • Requestless calls are stored once.
    • Their copies are bound in time by nothing measured: of 512,820 real usage lines, none lacked a requestId, and copies of the 184,502 message ids that do have one were at most 0.27 h apart.
    • So an ingest that stores a requestless call reads every day, not just the days around it, and the ledger holds the call once. A bounded report then agrees with the full one.
    • The one-day window stays for calls with a requestId.
    • Storing a requestless call costs a full ledger read: the checker measured 1,672 ms at 250,000 records, against 13 ms with a requestId.
  • Pending roots. A root that is unreadable or gone at the migration is kept as pending in the table and looked at again by every ingest. When it adds an id, roots.json and the ledger are rewritten again, so the repository ends with one id.
  • Canonical day order (57169330). The migration rewrites every day file, not only those a read finds unsettled, so each day is in the order one ingest writes it.
  • Store inventory, before/after rules and limits: contract.md.

🧪 How to verify

  • cd cli && pnpm exec vitest run tests/contexts/telemetry/application/legacy runs three fast-check properties:
    • For any earlier store (mixed-case ids, timestamped keys, copies across days, batches and the monthly layout), the migrated report on six axes and the ledger bytes equal those of one fresh ingest.
    • Migrating twice equals migrating once.
    • A failure at every write, then a rerun, equals a clean run.
  • Gates on the final commit:
    • cd cli && pnpm test: 6,809 passed, 1 skipped.
    • node --test 'scripts/__tests__/**/*.test.js': 461 of 461 passed.
    • pnpm smoke: PASS 132, FAIL 0.
    • Tokens end-to-end, plain and --attended: both passed.
    • pnpm test:mutation:telemetry: 97.2, floor 96 (full scope, before the review fixes).
    • Stryker on the lines changed since 57169330: 98.8, one equivalent survivor (the $ anchor of the advisor suffix; #advisor only ever appears as a key's suffix).
  • The window property now generates requestless copies up to five days apart, some written by an older CLI after the migration. The migration property generates copies 3 and 9 days apart, and clones unreadable or gone at the first run after the upgrade.
  • Red runs, hand mutations (23, then 12 more, all killed), the measurement, the first-run cost and the changed-line survivors: evidence.md.

⚠️ Heads-up

  • Stored-data migration. The first ingest after upgrade loads and rewrites the whole ledger once, and runs git once per root in roots.json. Measured: 4.5 s for 50 roots and 250,000 records, 20.8 s for 200 roots and 1,000,000 records, against the hook's 110 s. It adds ledger/repository-ids.json.
  • A new file on every machine. Every ingest that is not refused writes ledger/repository-ids.json once, on machines that never ran on too, with an empty table there.
  • Crash safety. Proven by a failure injected at every write. A stop during the removals that follow the writes, and a kill -9, are reasoned through, not exercised: the table stays incomplete until the rewrite ends, so the next run redoes it all.
  • An older CLI on migrated data. cli-v5.5.0 reads the lines as written and ignores the table. What it stores afterwards is re-keyed by the newer CLI at every read, and re-ided when its repository is in the table.
  • Limits:
    • A root unreadable or gone at migration stays pending. Until it can be read, its stored calls keep the earlier id and its new calls get the new one.
    • A damaged repository-ids.json is rebuilt from the clones still there. branches.jsonl is never rewritten, so it loses the alias of a repository with no clone left.
    • undated hashes in left-out.txt cannot be re-keyed. That file is unreleased: cli-v5.5.0 never wrote it.
    • A requestless call copied into another session still has two keys, as before.
    • A requestless call an older CLI stores after the migration may be counted by a report with a period until a report with no period settles the ledger. This needs a downgrade or a pinned older binary, and no requestless line was measured.
    • Hosts other than github.com keep owner and repo case.
  • Plugin hooks compute no repository id or key, so they are unchanged.
  • Bundle: 717,408 B of 726,016 B. Budget not raised.
  • Docs are kept small and local, because feat(telemetry): report envelope v2 keeps the figures apart from the run #1048 touches the same files.

🔗 Linked issue

None. Found by the R1 telemetry audit (M7, M10).

✅ I certify

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

blafourcade and others added 14 commits October 17, 2026 08:32
The host is case-folded on every form: WHATWG URL folds only special
schemes, so ssh:// and git:// kept it. ssh.github.com is github.com
(GitHub's SSH over port 443). Owner and repo are case-folded on
github.com only, which documents them as not case sensitive; any other
host keeps their case. Found by the R1 telemetry audit (M7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A call with no requestId was keyed message.id:sessionId:timestamp, so
copies of it written at different times were never folded and the
call was counted once per copy. The Messages API makes message.id a
unique object identifier, so message.id:sessionId names one call.
Found by the R1 telemetry audit (M10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Data stored by released CLIs carries the earlier ids and keys. Every
reader of the ledger, roots.json and branches.jsonl now re-keys a
timestamped key and re-ids through ledger/repository-ids.json, a table
from the id an earlier version gave a repository to its id now, made
once from each root's origin. The first ingest or report writes the
table, rewrites roots.json and every ledger partition, then marks it
complete, under the ledger lock, by write-then-rename: a run stopped at
any write is finished by the next. branches.jsonl stays append-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The usage contract states the requestless key, the repository address
rule and the new ledger/repository-ids.json store; the CLI memory
points at the migration. The task folder holds the contract with the
store inventory, the plan, the red runs and the hand mutations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
It settled only the partitions a read finds unsettled, and a read never
marks a day for its line order, so a day the new rules left alone kept
the order it was found in. It now saves every partition from one full
load; a day already canonical is not rewritten. Pins the counterexample
the fresh-ingest property found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…pread

Its key no longer carries the timestamp, and no real transcript bounds
how far apart its copies are written (none of 512,820 usage lines had
no requestId). An ingest storing one now reads every day, so the ledger
holds it once and a bounded report agrees with the full one. Calls with
a requestId keep the measured one-day window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A root unreadable or gone at the first run stayed unmapped while the
table was marked complete, so its repository got a second id. Such
roots are now kept pending and looked at again by every ingest; when
one adds an id, the stores are rewritten under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The migration and the snapshot store's alias table are required, so a
wiring that leaves one out fails to compile. Re-keying an earlier key
applies to claude-code records only. Fixes a stale class name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The usage contract and the task contract state that a requestless call
is stored once, that a root unread at migration stays pending until it
can be read, and what a damaged table costs branches.jsonl. Evidence
holds the measurement, the red runs, the first-run cost and the round
one review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The re-keying and the requestless test shared a copy of the advisor
suffix pattern; they now use the format's own, pinned with a two-digit
iteration. The migration's early return for a complete table with
nothing pending changed nothing: that run locates nothing and writes
nothing, so the guard is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…erties

The migration property's first run now has clones unreadable or gone,
back by the next. A failure at every write of a pending root's retry
ends as a clean retry. The window property generates requestless
lines an older CLI writes after the migration and exempts the bounded
read for those calls alone, the stated limit. The ledger and roots
adapters say why they build their alias table themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant