Skip to content

fix(telemetry): read a transcript a block at a time - #1047

Merged
blafourcade merged 14 commits into
nextfrom
fix/transcripts-are-read-as-a-stream
Oct 17, 2026
Merged

blafourcade merged 14 commits into
nextfrom
fix/transcripts-are-read-as-a-stream

Conversation

@blafourcade

@blafourcade blafourcade commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

🎯 What & why

Ingest used to read the whole unread tail of a Claude Code transcript into memory at once: one buffer, then one string, then one string per line, all alive together. The audit reports transcripts of 371 MB and 417 MB. Memory therefore grew with the tail, and a tail over 512 MB (V8's longest string) cannot be read at all (inferred, not run). Ingest now reads a transcript 1 MiB at a time and produces the same lines, positions and ledger bytes as before.

🛠️ How it works

  • Port. TranscriptSource.read now takes an each(line) callback and returns only {position, restarted, skipped} (transcript-source.ts). Streaming in the adapter alone would not have been enough, because an array of lines still holds the whole tail. ReadUsageUseCase moved its loop body into the callback. Ingest, the hold-back from fix(telemetry): nothing measured is lost silently #1046 and everything downstream are untouched.
  • Adapter. eachLine in claude-transcript-source-adapter.ts:41 reads 1 MiB blocks from the handle it already stat'd, and stops at the size it stat'd.
    • It splits on byte 0x0a and keeps a line that spans blocks as pieces, joined once at its newline.
    • It decodes each line on its own. 0x0a never occurs inside a UTF-8 sequence, so this decodes exactly like the whole tail did, invalid bytes and BOM included.
    • Resume rules are unchanged: the saved offset is just after the last complete line, an unterminated last line is read again next time, and a shrunk or replaced file is read from the start.
  • A file that changes during a read (review round 1). A read now takes many block reads, so a transcript can change between two of them. After the last block, the adapter checks the open handle's size again. If the file got shorter, the read returns no position. A file replaced at its path is not discarded: the open handle still reads the old file whole and consistently, and the next read restarts on the new file (review round 2). ReadUsageUseCase then keeps none of that file's records or lines, so ingest keeps the old position and the next ingest reads the same bytes again.
  • Decoding without a copy. A line that sits entirely in one block is decoded in place, without a Buffer.concat copy.
  • Rejected alternatives.
    • readline: it breaks on a lone \r and gives no byte offsets.
    • createReadStream(path): it reopens the path, which may by then be another file.
    • TextDecoder: it strips a BOM on every call.
  • Block size. It is a constructor parameter, so tests reach every boundary with blocks of a few bytes.

🧪 How to verify

  • cd cli && pnpm exec vitest run tests/contexts/telemetry. The relevant tests:

    • Boundaries, at blocks of 1 to 5 bytes: a line or character split across blocks, CRLF, no final newline, empty lines, a line many blocks long, a file growing or truncated during the read, a saved offset past the size, an empty file.
    • Property S1, against the old reader kept as an oracle in whole-transcript-reader.ts: same lines and positions for any bytes, saved position and block size.
    • Property S2: the same ledger, offsets.json and left-out.txt bytes and the same ingest result, across ingests of transcripts cut mid-line, with a held clone.
  • Peak memory on generated transcripts, as max RSS (bare node: 44 MB):

    Transcript origin/next this branch
    151 MB 371 MB 62 MB
    402 MB not run 67 MB

    Rerun it with memory-bench.mjs: node memory-bench.mjs <MB> [<cli src dir>].

  • Gates, red runs and hand mutations are in evidence.md. Telemetry mutation score is 96.6. Bundle is 696.0 KB of a 709 KB budget, with no raise.

⚠️ Heads-up

  • A single line is still held whole. Memory is one block, up to about three copies of the longest line, and every record kept, and a single line over 512 MB still cannot be decoded. This is recorded in the usage contract's residual limits.
  • A transcript rewritten in place to the same size or larger during a read is not detected. A line may then be spliced from the two versions. Claude Code only appends to transcripts. This is recorded in the usage contract's residual limits.
  • Independent review rounds 1 and 2: their findings are applied (review.md).

🔗 Linked issue

None. Found by the R1 telemetry audit (M3).

✅ 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 03:46
The R1 telemetry audit (M3) found ingest reads a transcript's unread
tail whole. Contract, plan and phases for reading it a block at a time
with the same lines and positions, byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Ingest read a transcript's unread tail into one buffer and one string,
then one string per line, all alive at once: memory grew with the
tail, and a tail over 512 MB cannot be one string (inferred, not run).
The port now hands each complete line on as it is found, and the
adapter reads 1 MiB at a time from the handle it stat'd, up to the size
stat'd. Lines, positions and ledger bytes are the same as the
whole-tail reader's, which the tests keep as an oracle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Two mutants on the lines the stream touched survived the telemetry
mutation run: `restarted: true` for a file that vanished and for one
skipped as old. Both are now asserted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Red runs, the hand mutation behind each guard, peak memory before and
after on generated transcripts, the mutation score and every gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A hand mutation for the empty-line, offset-past-size and empty-file
guards, and counts showing the ingest property stores calls and holds
positions. The audit's transcript sizes are attributed to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A read now spans many block reads, so a transcript truncated or
rewritten in place between two of them could hand over a line spliced
from two versions and save an offset inside the new one. After the last
block the adapter stats the open handle and the path: shorter than when
the read began, or another file at the path, and it returns no
position. The reading then keeps none of that file's records or lines
and ingest keeps its position, so the next ingest reads it again.

A line read whole from one block is now decoded in place, without the
copy Buffer.concat made. The docs state the memory bound in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The open handle still holds a deleted transcript whole, so its lines
are consistent and keep their position; a torn read is not skipped.
Both were mutants alive after the torn-read change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The bench that measured peak memory is kept beside the evidence so it
can be rerun, and was: 371 MB before, 62 MB after at 151 MB, 67 MB at
402 MB. Red run, hand mutations, mutation score and gates of round 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The open handle reads a file replaced at its path whole and
consistently, so discarding that read lost the old file's lines, and a
path rotated at every read would never be counted. Only a file that
shrank under the read is discarded now; a replaced one keeps its lines
and identity, and the next read restarts on the new file.

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

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 03:39
@blafourcade
blafourcade requested a review from a team as a code owner October 17, 2026 03:39
@blafourcade
blafourcade merged commit 616dbbe 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