Skip to content

fix(telemetry): nothing measured is lost silently - #1046

Merged
blafourcade merged 21 commits into
nextfrom
fix/nothing-is-lost-silently
Oct 17, 2026
Merged

blafourcade merged 21 commits into
nextfrom
fix/nothing-is-lost-silently

Conversation

@blafourcade

@blafourcade blafourcade commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

🎯 What & why

This PR closes two gaps (M1 and M2 from the R1 telemetry audit) where telemetry lost data without saying so.

  • M1. The plugin's catch-up hook runs aidd telemetry ingest --quiet. What that ingest measured but could not store was counted for its own run only, then the offsets moved on, so a report run later showed 0.
  • M2. Rewriting a ledger partition deleted every line the reader did not recognise. An older CLI could therefore erase a newer CLI's records.

🛠️ How it works

  • M2: unknown lines are kept. usage-ledger-adapter.ts:96 save keeps every non-empty line that is not a record.
    • The line stays in its own day, after the records, sorted, and each line once. A monthly file's lines go to the first day of that month.
    • A day holding only such lines is never removed.
    • skipped_ledger_lines counts these lines on every read.
    • Why keep them in the day and not in a quarantine file: a newer CLI finds its lines where it wrote them.
  • M1: only what was measured is kept, and only as a hash. Rules are in usage-contract.md:81; the logic is at ingest-usage-use-case.ts:109.
    • A call excluded by consent or scope is not measured: it is counted by the run that read it and nothing of it is written. This covers a clone that never opted in, work after off, calls outside any repository, and directories never seen alive.
    • Two kinds of measured-but-unstorable data are kept, both only for a clone that consents:
      • an undated call;
      • a transcript line that is no usage line but names that clone's directory.
    • They go to ledger/left-out.txt as <reason> <32-hex sha-256>, one line each (41 or 46 bytes), each once, and never as a key or text in clear.
    • The store is its own small adapter (left-out-adapter.ts); the ledger only exposes it as leftOut.
  • A call whose consent can't be read yet is read again. Examples: git refuses the .git once, or the config doesn't parse.
    • unreadable-consent is not a refusal when a clone that roots.json remembers at that directory consented at the call's time: that clone may consent again once it can be read. Undated calls and unrecognised lines follow the same rule.
    • Anything else moves on, and only that run reports it: a directory never seen readable, a repository that never opted in, or a call made while the clone did not consent. Once readable, those calls could never be stored, so a hold would never end. The same applies to a damaged consent log, which stays damaged until forget removes it.
    • Ingest keeps the position of each transcript that holds such a call from before that read (ingest-usage-use-case.ts:114). The next ingest, including the one every report runs, reads it again.
    • The call is stored once its consent can be read. Until then, every run reports it. Nothing is written to disk meanwhile.
  • Envelope. It stays version: 1, with one field added: coverage.left_out: {undated, unrecognised}. It counts everything kept by every ingest so far, so it has the same value in every report, bounded or not. files_read, unrecognised_shapes and not_stored keep their meaning: the current run's.
  • Bundle budget. Telemetry code was first trimmed by 1.7 KB, in its own refactor commits. The budget was then raised in its own commit, chore(cli): raise the bundle budget to 709 KB, as the policy in check-bundle-size.mjs requires, and recorded in its registry. Measured size: 712,014 B (695.3 KB), 2.0% headroom.

🧪 How to verify

  • cd cli && pnpm test: Tests 6721 passed | 1 skipped (6722)
  • node --test 'scripts/__tests__/**/*.test.js': pass 460, fail 0
  • pnpm test:mutation:telemetry: score 96.6 (floor 96). The survivors on changed lines are explained in evidence.md, which also records the red runs, the hand mutations, smoke, and the token end-to-end check in both modes.
  • Properties (fast-check):
    • left-out.property: any sequence of ingests, re-reads and offset resets, including while a clone that consents cannot be read, gives the same bytes and envelope as one ingest. left_out matches an independent oracle, is the same bounded or unbounded, and is never written for a clone that never opted in.
    • report-window.property: with left-out.txt present, a bounded report equals the full one filtered.
    • ledger-upkeep.property: byte identity holds with unknown lines present, and none is ever lost.
  • End to end: telemetry-journey.e2e checks two things.
    • A session only in a clone that never opted in leaves no left-out.txt.
    • A report run after the catch-up hook shows left_out.

⚠️ Heads-up

  • left_out is cumulative. It counts everything every ingest has kept so far, and does not reset per run or per period, so do not sum it across reports. not_stored and unrecognised_shapes still describe the current run only.
  • ingest output changed. Counts are grouped by thousands, the snapshot line comes first, and each line's output level comes from its type.
  • Gone worktrees are a known loss. A call from a directory that was gone before any ingest saw it alive (never-seen-alive, for example a worktree created and removed between two ingests) is reported only by the run that read it, and the hook's run prints nothing. Its consent is unknown, so nothing may be kept, and the directory never comes back, so re-reading it would never end. A claude --worktree session's start sees its directory alive; a subagent's worktree has no such recovery. A SubagentStart or SubagentStop hook might cover it; that is unverified and out of scope.
  • roots.json may name a non-opted clone. Resolving the directory of an undated call or an unrecognised line records the directory in roots.json when it is alive, in any clone. Dated calls already did this before this PR. It records a directory, never a call. The guarantee "a clone that never opted in leaves nothing" therefore applies to left-out.txt, not to roots.json.
  • Cost of an unreadable clone. While a clone that consents stays unreadable, every ingest re-reads the part of its transcripts read since it became unreadable, until the clone is repaired.
  • Run-only wording. Report lines that describe only the current run now say "by this read".
  • Copy assumption. A call is assumed to have its copies all dated or all undated. The contract states this, and the property's generator respects it.
  • Undated calls and consent. An undated call is judged by its clone's consent at the time of the read, because it has no time of its own.
  • Lines that name no directory. Such a line cannot be tied to a clone, so only the run that read it reports it.
  • cli-v5.5.0 still deletes ledger lines it does not recognise.
  • Review. Checker rounds 1 to 3 are recorded in review.md, and every finding from all three is addressed in this push. No checker has reviewed the result yet.
  • Unrelated, not fixed. forget pluralises "repository" as "repositorys".

🔗 Linked issue

None. Found by the R1 telemetry audit (M1, M2).

✅ 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 21 commits October 17, 2026 00:05
Each telemetry command repeated the same try, dependency wiring and
error handling; the nine filter flags were listed twice, by the command
and by the envelope. One helper each, same behaviour, room in the bundle
for the next change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A partition rewritten by settle or ingest dropped every line that was
not a record, so an older CLI erased a newer one's records and a torn
line vanished unsaid. A rewrite now keeps each such line in its day,
after the records, in text order and once; a monthly file's go to the
first day of its month. A day holding only such lines stays, and
skipped_ledger_lines counts them on every read.

The byte-identity property of a full settle now holds with such lines
present, and a second one checks that no ingest or settle, bounded or
not, ever loses one.

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

The ingest and the report each printed the shapes not recognised, the
calls not stored, a damaged consent log and the ledger lines kept, with
their own wording of the counts; both now print the same lines, so
`ingest` groups thousands as the report does and prints its snapshot
count before them. The identity rule and the consent-log recovery
sentence are written once; the four counters are listed once by the
report and its envelope; the commands share one wiring path; the
absent-path test is one function. Same output otherwise: room in the
bundle for the next change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The catch-up hook runs `aidd telemetry ingest --quiet`, which printed
nothing and moved the offsets on, so the calls it did not store and the
lines that were no usage line were counted for that run only: a report
run later read nothing new and printed 0.

An ingest now keeps what it leaves out under ledger/left-out/: a call
with a day by its key, agent, day and reason, in the ledger's own form,
fold and settle; an undated call and a line that is no usage line as a
hash in no-day.txt. Nothing else of a call is kept, and no transcript
text. A report counts the left-out calls of its period that no read
stored since, and every entry with no day. Keyed, so a re-read or an
offsets reset counts nothing twice; a call is dated by its earliest
copy, so the bytes do not depend on how many ingests read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The shared left-out lines sent every one of them to stderr, so a call
not stored stopped reaching stdout. It is information again; a line
that could not be read stays a warning, each level now pinned by a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The ledger directory now holds left-out/; three ingests started
together leave one line in it for the one call not stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A line that is neither a call nor a shape not recognised (a prompt) is
never kept as dropped; two undated calls are two; a telemetry directory
that cannot be looked into fails instead of reading as empty. The days
a save writes need no order: every write comes before any removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A call with no requestId is keyed by message id, session id and time,
so its left-out entry holds both: the contract, the usage contract and
the code now say so instead of "no session". The reach and the
unmeasured read cost of left-out/ are stated as limits, and the gate
evidence names the commit it was taken on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Review round 1 found the left-out store kept the key of every call not
stored, a clone that never opted in included: the product rule is that
such a clone, or work after `off`, leaves nothing on disk.

What consent or scope excludes is no longer kept: it is counted by the
run that read it, as before. Only what was measured and cannot be
stored is kept, in ledger/left-out.txt, by a 32-hex hash: an undated
call of a clone that consents, and a line that is no usage line naming
such a clone's directory. Reports print it as coverage.left_out, the
same in every report; not_stored and unrecognised_shapes keep their
run-only meaning, so ingest and report count shapes alike. The store is
its own small adapter; the per-day left-out partitions, the earliest-copy
dating and the report-time matching are gone. Ingest groups `updated`
and picks each line's level from its type, not its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A call made before its clone consented is not kept though it consents
now; an undated call naming no directory is never resolved, wherever
git would look; a line with an unusable time is judged at the read;
the kept counts say one and many. A line that gives a call names
nothing, so the reader's guard on it is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Review round 2: a call judged unreadable-consent (git refusing the
.git once, a config that does not parse) was counted by the hook's
quiet run only, then the offsets moved on, so a repaired clone never
got it back and no report said it. That is not a no: ingest now keeps
the position of each transcript holding such a call from before the
read, so the next ingest, a report's included, reads it again; it is
stored once readable, and every run says it until then. Nothing is
written. A damaged consent log still moves on: forget is its way out.

A call of a directory gone before any ingest saw it alive stays a
stated loss: its consent is unknown and reading it again would never
end. Run-only report lines now say "by this read". The usage contract
quotes the current wording, names the undated exception in Consent,
and notes roots.json beside its row.

Bytes for it: the lock and the report destructure what they use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Measured 695.3 KB with the left-out store, kept ledger lines and the
bounded re-read; 2.0% headroom, recorded in the registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Review round 3: a directory git refuses for good (a repository owned by
another user that never opted in, an unmounted share) held every
transcript with one call there, re-read by each ingest without end.

A transcript is now held only when a clone roots.json remembers at that
directory consented at the call's time: repairing that clone ends the
hold. Anything else moves on and only that read says it. An undated
call and a line that is no usage line follow the same rule, so a
consenting clone's are kept once it can be read. A damaged consent log
moves on because it stays damaged until forget, not because forget
reads again. P3 now has a consenting clone git refuses until the last
ingest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade
blafourcade marked this pull request as ready for review October 17, 2026 01:32
@blafourcade
blafourcade requested a review from a team as a code owner October 17, 2026 01:32
@blafourcade
blafourcade merged commit 1202ff5 into next Oct 17, 2026
61 checks passed
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