Showing the changes to apps/server/src/usage/usageTranscriptReader.ts (1 of 6 files). Show all
Unified · Split
| @@ -4,16 +4,19 @@ · 3 unmodified lines | |||
| 4 | * | 4 | * |
| 5 | * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. | 5 | * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. |
| 6 | * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB | 6 | * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB |
| 7 | * across ~1,500 files, and `readline` over a read stream is roughly an order of | 7 | * across ~1,500 files, and buffer-level streaming is roughly an order of |
| 8 | * magnitude cheaper than materialising each file. The equivalent Effect stream | 8 | * magnitude cheaper than materialising each file. The equivalent Effect stream |
| 9 | * pipeline is idiomatic but not fast enough to sit behind a page load. | 9 | * pipeline is idiomatic but not fast enough to sit behind a page load. |
| 10 | * | 10 | * |
| 11 | * Transcripts are append-only, so a parse also reports the byte position it | ||
| 12 | * stopped at. A later scan of the same file resumes from that position and | ||
| 13 | * parses only the appended bytes, which is what keeps a warm scan cheap while a | ||
| 14 | * session is actively writing a multi-hundred-megabyte rollout. | ||
| 15 | * | ||
| 11 | * @module usageTranscriptReader | 16 | * @module usageTranscriptReader |
| 12 | */ | 17 | */ |
| 13 | import * as NodeFS from "node:fs"; | ||
| 14 | import * as NodeFSP from "node:fs/promises"; | 18 | import * as NodeFSP from "node:fs/promises"; |
| 15 | import * as NodePath from "node:path"; | 19 | import * as NodePath from "node:path"; |
| 16 | import * as NodeReadline from "node:readline"; | ||
| 17 | 20 | ||
| 18 | import type { UsageProviderKind } from "@t3tools/contracts"; | 21 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 19 | 22 | ||
| @@ -23,6 +26,7 @@ import { · 3 unmodified lines | |||
| 23 | parseClaudeLine, | 26 | parseClaudeLine, |
| 24 | parseCodexLine, | 27 | parseCodexLine, |
| 25 | parseGrokLine, | 28 | parseGrokLine, |
| 29 | type CodexScanState, | ||
| 26 | type UsageRecord, | 30 | type UsageRecord, |
| 27 | } from "./usageTranscripts.ts"; | 31 | } from "./usageTranscripts.ts"; |
| 28 | 32 | ||
| @@ -32,6 +36,56 @@ export interface TranscriptFile { · 3 unmodified lines | |||
| 32 | readonly mtimeMs: number; | 36 | readonly mtimeMs: number; |
| 33 | } | 37 | } |
| 34 | 38 | ||
| 39 | /** | ||
| 40 | * Where a parse stopped, with enough state to continue from there. | ||
| 41 | * | ||
| 42 | * The guard hash fingerprints the bytes immediately before `resumeOffset`. A | ||
| 43 | * resume only proceeds when those bytes still match: transcripts are | ||
| 44 | * append-only by design, but a rotated or rewritten file silently mis-parsed | ||
| 45 | * from the middle would corrupt usage totals. The window is a cheap tripwire | ||
| 46 | * for those realistic failure shapes, all of which disturb the file's tail at | ||
| 47 | * that exact offset; it deliberately does not hash the whole prefix, which | ||
| 48 | * would cost the full re-read the resume exists to avoid. | ||
| 49 | */ | ||
| 50 | export interface TranscriptParsePosition { | ||
| 51 | /** Byte offset just past the last newline-terminated line consumed. */ | ||
| 52 | readonly resumeOffset: number; | ||
| 53 | /** Length of the fingerprinted window ending at `resumeOffset`. */ | ||
| 54 | readonly guardLength: number; | ||
| 55 | /** FNV-1a hash of that window. */ | ||
| 56 | readonly guardHash: number; | ||
| 57 | /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ | ||
| 58 | readonly codexState: CodexScanState | null; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface TranscriptParseResult { | ||
| 62 | /** Records from newline-terminated lines at or after the parse start. */ | ||
| 63 | readonly records: readonly UsageRecord[]; | ||
| 64 | /** | ||
| 65 | * Records from a trailing segment the writer has not newline-terminated yet. | ||
| 66 | * Kept out of `records` because `position` deliberately excludes that | ||
| 67 | * segment: the next scan re-reads it once the writer finishes the line. | ||
| 68 | */ | ||
| 69 | readonly tailRecords: readonly UsageRecord[]; | ||
| 70 | readonly position: TranscriptParsePosition; | ||
| 71 | /** Whether the parse continued from `resumeFrom` rather than byte 0. */ | ||
| 72 | readonly resumed: boolean; | ||
| 73 | } | ||
| 74 | |||
| 75 | /** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ | ||
| 76 | export const GUARD_LENGTH = 64; | ||
| 77 | const NEWLINE = 0x0a; | ||
| 78 | const CARRIAGE_RETURN = 0x0d; | ||
| 79 | |||
| 80 | function fnv1a(buffer: Buffer): number { | ||
| 81 | let hash = 0x811c9dc5; | ||
| 82 | for (let index = 0; index < buffer.length; index += 1) { | ||
| 83 | hash ^= buffer[index]!; | ||
| 84 | hash = Math.imul(hash, 0x01000193); | ||
| 85 | } | ||
| 86 | return hash >>> 0; | ||
| 87 | } | ||
| 88 | |||
| 35 | /** | 89 | /** |
| 36 | * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. | 90 | * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. |
| 37 | * | 91 | * |
| @@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 62 unmodified lines | |||
| 100 | } | 154 | } |
| 101 | } | 155 | } |
| 102 | 156 | ||
| 157 | async function guardMatches( | ||
| 158 | handle: NodeFSP.FileHandle, | ||
| 159 | position: TranscriptParsePosition, | ||
| 160 | ): Promise<boolean> { | ||
| 161 | if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; | ||
| 162 | try { | ||
| 163 | const window = Buffer.alloc(position.guardLength); | ||
| 164 | const { bytesRead } = await handle.read( | ||
| 165 | window, | ||
| 166 | 0, | ||
| 167 | position.guardLength, | ||
| 168 | position.resumeOffset - position.guardLength, | ||
| 169 | ); | ||
| 170 | return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; | ||
| 171 | } catch { | ||
| 172 | return false; | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 103 | /** | 176 | /** |
| 104 | * Streams one transcript and returns the usage records it contains, or `null` | 177 | * Streams one transcript and returns the usage records it contains, or `null` |
| 105 | * when the file could not be read. | 178 | * when the file could not be read. |
| @@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 3 unmodified lines | |||
| 109 | * under the same `(size, mtime)` key would silently drop that file's usage | 182 | * under the same `(size, mtime)` key would silently drop that file's usage |
| 110 | * until the file next changes. | 183 | * until the file next changes. |
| 111 | * | 184 | * |
| 185 | * With `resumeFrom`, parsing continues from that position when its guard bytes | ||
| 186 | * still match, so only appended lines are read; otherwise the whole file is | ||
| 187 | * re-parsed from the start and `resumed` reports `false`. | ||
| 188 | * | ||
| 112 | * Codex carries the active model on `turn_context` lines that hold no usage of | 189 | * Codex carries the active model on `turn_context` lines that hold no usage of |
| 113 | * their own, so those still have to pass through the reducer to keep model | 190 | * their own, so those still have to pass through the reducer to keep model |
| 114 | * attribution correct. | 191 | * attribution correct. |
| @@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise<string> { · 1 unmodified line | |||
| 116 | export async function readTranscriptRecords( | 193 | export async function readTranscriptRecords( |
| 117 | filePath: string, | 194 | filePath: string, |
| 118 | provider: UsageProviderKind, | 195 | provider: UsageProviderKind, |
| 119 | ): Promise<readonly UsageRecord[] | null> { | 196 | resumeFrom?: TranscriptParsePosition, |
| 120 | const records: UsageRecord[] = []; | 197 | ): Promise<TranscriptParseResult | null> { |
| 121 | const codexState = initialCodexScanState(); | 198 | let handle: NodeFSP.FileHandle; |
| 199 | try { | ||
| 200 | handle = await NodeFSP.open(filePath, "r"); | ||
| 201 | } catch { | ||
| 202 | return null; | ||
| 203 | } | ||
| 122 | 204 | ||
| 123 | try { | 205 | try { |
| 124 | const lines = NodeReadline.createInterface({ | 206 | let codexState = initialCodexScanState(); |
| 125 | input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), | 207 | let resumed = false; |
| 126 | crlfDelay: Infinity, | 208 | let start = 0; |
| 127 | }); | 209 | if ( |
| 210 | resumeFrom !== undefined && | ||
| 211 | resumeFrom.resumeOffset > 0 && | ||
| 212 | (provider !== "codex" || resumeFrom.codexState !== null) && | ||
| 213 | (await guardMatches(handle, resumeFrom)) | ||
| 214 | ) { | ||
| 215 | if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; | ||
| 216 | start = resumeFrom.resumeOffset; | ||
| 217 | resumed = true; | ||
| 218 | } | ||
| 128 | 219 | ||
| 129 | for await (const line of lines) { | 220 | const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { |
| 130 | if (provider === "codex") { | 221 | if (provider === "codex") { |
| 131 | if ( | 222 | if ( |
| 132 | !mightCarryUsage(line, provider) && | 223 | !mightCarryUsage(line, provider) && |
| 133 | !line.includes('"turn_context"') && | 224 | !line.includes('"turn_context"') && |
| 134 | !line.includes('"session_meta"') | 225 | !line.includes('"session_meta"') |
| 135 | ) { | 226 | ) { |
| 136 | continue; | 227 | return; |
| 137 | } | 228 | } |
| 138 | const record = parseCodexLine(line, codexState); | 229 | const record = parseCodexLine(line, state); |
| 139 | if (record !== null) records.push(record); | 230 | if (record !== null) out.push(record); |
| 140 | continue; | 231 | return; |
| 141 | } | 232 | } |
| 142 | 233 | if (!mightCarryUsage(line, provider)) return; | |
| 143 | if (provider === "grok") { | 234 | if (provider === "grok") { |
| 144 | if (!mightCarryUsage(line, provider)) continue; | 235 | for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); |
| 145 | for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); | 236 | return; |
| 237 | } | ||
| 238 | const record = parseClaudeLine(line); | ||
| 239 | if (record !== null) out.push(record); | ||
| 240 | }; | ||
| 241 | |||
| 242 | const toLineString = (lineBuffer: Buffer): string => { | ||
| 243 | const content = | ||
| 244 | lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN | ||
| 245 | ? lineBuffer.subarray(0, -1) | ||
| 246 | : lineBuffer; | ||
| 247 | return content.toString("utf8"); | ||
| 248 | }; | ||
| 249 | |||
| 250 | const records: UsageRecord[] = []; | ||
| 251 | // Buffer-level line splitting rather than `readline`, because resuming | ||
| 252 | // needs byte-exact offsets and decoded strings cannot provide them. | ||
| 253 | // Newline-free chunks are collected rather than concatenated as they | ||
| 254 | // arrive, so a single huge line costs one copy instead of one per chunk. | ||
| 255 | let resumeOffset = start; | ||
| 256 | let pendingChunks: Buffer[] = []; | ||
| 257 | const stream = handle.createReadStream({ | ||
| 258 | start, | ||
| 259 | autoClose: false, | ||
| 260 | }) as AsyncIterable<Buffer>; | ||
| 261 | for await (const chunk of stream) { | ||
| 262 | if (!chunk.includes(NEWLINE)) { | ||
| 263 | pendingChunks.push(chunk); | ||
| 146 | continue; | 264 | continue; |
| 147 | } | 265 | } |
| 266 | const buffer: Buffer = | ||
| 267 | pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); | ||
| 268 | pendingChunks = []; | ||
| 269 | let lineStart = 0; | ||
| 270 | for (;;) { | ||
| 271 | const newlineIndex = buffer.indexOf(NEWLINE, lineStart); | ||
| 272 | if (newlineIndex === -1) break; | ||
| 273 | parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); | ||
| 274 | lineStart = newlineIndex + 1; | ||
| 275 | } | ||
| 276 | resumeOffset += lineStart; | ||
| 277 | if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); | ||
| 278 | } | ||
| 148 | 279 | ||
| 149 | if (!mightCarryUsage(line, provider)) continue; | 280 | // A trailing segment without its newline is parsed for this result but not |
| 150 | const record = parseClaudeLine(line); | 281 | // consumed: a writer may still be appending to it, and counting a half |
| 151 | if (record !== null) records.push(record); | 282 | // record now and its full form later would double count. |
| 283 | const tailRecords: UsageRecord[] = []; | ||
| 284 | if (pendingChunks.length > 0) { | ||
| 285 | const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); | ||
| 286 | if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); | ||
| 152 | } | 287 | } |
| 288 | |||
| 289 | const guardLength = Math.min(GUARD_LENGTH, resumeOffset); | ||
| 290 | let guardHash = 0; | ||
| 291 | if (guardLength > 0) { | ||
| 292 | const window = Buffer.alloc(guardLength); | ||
| 293 | await handle.read(window, 0, guardLength, resumeOffset - guardLength); | ||
| 294 | guardHash = fnv1a(window); | ||
| 295 | } | ||
| 296 | |||
| 297 | return { | ||
| 298 | records, | ||
| 299 | tailRecords, | ||
| 300 | position: { | ||
| 301 | resumeOffset, | ||
| 302 | guardLength, | ||
| 303 | guardHash, | ||
| 304 | codexState: provider === "codex" ? codexState : null, | ||
| 305 | }, | ||
| 306 | resumed, | ||
| 307 | }; | ||
| 153 | } catch { | 308 | } catch { |
| 154 | return null; | 309 | return null; |
| 310 | } finally { | ||
| 311 | await handle.close().catch(() => undefined); | ||
| 155 | } | 312 | } |
| 156 | |||
| 157 | return records; | ||
| 158 | } | 313 | } |