| author | |
| committer | |
| log | 71dbaf1f94b580491d033f2e347a9b1c507fd793 |
| tree | b487f2087a351fb4e0eeb6ed27b21b8b0fcb7ff9 |
| parent | 39efcd855833a1486ff7662e2efcedeeee6be0cc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>9 files changed, 579 insertions(+), 131 deletions(-)
apps/server/src/usage/UsageService.test.ts+187| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real |
| 2 | 2 | // transcript trees on disk, outside the service's Effect FileSystem. |
| 3 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | 4 | import * as NodeFSP from "node:fs/promises"; |
| 4 | 5 | import * as NodeOS from "node:os"; |
| 5 | 6 | import * as NodePath from "node:path"; |
| ... | ... | @@ -117,6 +118,41 @@ const serviceLayers = (input: { |
| 117 | 118 | ), |
| 118 | 119 | ); |
| 119 | 120 | |
| 121 | /** Outside `NARROW_WINDOW`, inside `WINDOW`. Seconds, as `utimes` takes them. */ | |
| 122 | const BEFORE_NARROW_WINDOW = Date.parse("2026-08-01T10:00:00Z") / 1000; | |
| 123 | const NARROW_WINDOW: UsageSummaryInput = { | |
| 124 | timeZone: "UTC", | |
| 125 | sinceDay: UsageDay.make("2026-09-01"), | |
| 126 | untilDay: UsageDay.make("2026-09-02"), | |
| 127 | }; | |
| 128 | ||
| 129 | /** | |
| 130 | * A FIFO named like a transcript. A scan's read of it waits in `open` until | |
| 131 | * `openGate`, then fails at once, so a gate holds that scan's directory reads | |
| 132 | * in flight. Each waiting gate holds one libuv pool thread; keep at most three. | |
| 133 | */ | |
| 134 | const makeGate = (path: string, lastWriteSeconds?: number) => | |
| 135 | Effect.promise(async () => { | |
| 136 | NodeChildProcess.execFileSync("mkfifo", [path]); | |
| 137 | if (lastWriteSeconds !== undefined) { | |
| 138 | await NodeFSP.utimes(path, lastWriteSeconds, lastWriteSeconds); | |
| 139 | } | |
| 140 | }); | |
| 141 | ||
| 142 | /** | |
| 143 | * Returns once a scan has opened the gate. A scan opens every file of a | |
| 144 | * directory at once, so it then holds the directory's transcripts open too. | |
| 145 | */ | |
| 146 | const openGate = (path: string) => | |
| 147 | Effect.promise(async () => (await NodeFSP.open(path, "w")).close()); | |
| 148 | ||
| 149 | /** Replaces a file by rename, so a scan holding the old one keeps reading it. */ | |
| 150 | const replaceFile = (path: string, content: string) => | |
| 151 | Effect.promise(async () => { | |
| 152 | await NodeFSP.writeFile(path + ".next", content); | |
| 153 | await NodeFSP.rename(path + ".next", path); | |
| 154 | }); | |
| 155 | ||
| 120 | 156 | function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { |
| 121 | 157 | return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); |
| 122 | 158 | } |
| ... | ... | @@ -972,6 +1008,157 @@ describe("UsageService", () => { |
| 972 | 1008 | }).pipe(Effect.scoped), |
| 973 | 1009 | ); |
| 974 | 1010 | |
| 1011 | it.live("credits the same copy of a duplicate after its transcripts are deleted", () => | |
| 1012 | Effect.gen(function* () { | |
| 1013 | const { transcript, settings, home } = yield* setup; | |
| 1014 | const dir = NodePath.dirname(transcript); | |
| 1015 | // The walk-first file is the original, padded so it finishes parsing | |
| 1016 | // after the small fork copy that repeats its record under a new session. | |
| 1017 | const [first = "", second = ""] = yield* Effect.promise(async () => { | |
| 1018 | await NodeFSP.writeFile(NodePath.join(dir, "a.jsonl"), ""); | |
| 1019 | await NodeFSP.writeFile(NodePath.join(dir, "b.jsonl"), ""); | |
| 1020 | return (await NodeFSP.readdir(dir)).map((name) => NodePath.join(dir, name)); | |
| 1021 | }); | |
| 1022 | const forked = (line: string) => line.replace('"session-1"', '"session-2"'); | |
| 1023 | yield* Effect.promise(async () => { | |
| 1024 | await NodeFSP.writeFile( | |
| 1025 | first, | |
| 1026 | claudeLine(1, 5).replace( | |
| 1027 | '"message":', | |
| 1028 | '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', | |
| 1029 | ), | |
| 1030 | ); | |
| 1031 | await NodeFSP.writeFile(second, forked(claudeLine(1, 5)) + forked(claudeLine(2, 7))); | |
| 1032 | }); | |
| 1033 | yield* Effect.gen(function* () { | |
| 1034 | const service = yield* UsageService.make; | |
| 1035 | const live = yield* service.readSummary(WINDOW); | |
| 1036 | assert.strictEqual(live.buckets[0]?.sessions, 2); | |
| 1037 | ||
| 1038 | yield* Effect.promise(() => Promise.all([NodeFSP.rm(first), NodeFSP.rm(second)])); | |
| 1039 | const saved = yield* service.readSummary(WINDOW); | |
| 1040 | assert.deepStrictEqual(saved.buckets, live.buckets); | |
| 1041 | assert.deepStrictEqual(saved.sources, live.sources); | |
| 1042 | const restored = yield* (yield* UsageService.make).readSummary(WINDOW); | |
| 1043 | assert.deepStrictEqual(restored.buckets, live.buckets); | |
| 1044 | }).pipe( | |
| 1045 | Effect.provide(serviceLayers({ prefix: "usage-service-copy-order-test", home, settings })), | |
| 1046 | ); | |
| 1047 | }).pipe(Effect.scoped), | |
| 1048 | ); | |
| 1049 | ||
| 1050 | it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 1051 | "keeps a newer cached read when a slower scan of another window finishes later", | |
| 1052 | () => | |
| 1053 | Effect.gen(function* () { | |
| 1054 | const { transcript, settings, home } = yield* setup; | |
| 1055 | const dir = NodePath.dirname(transcript); | |
| 1056 | const probe = NodePath.join(dir, "probe.jsonl"); | |
| 1057 | const hold = NodePath.join(dir, "hold.jsonl"); | |
| 1058 | yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); | |
| 1059 | yield* makeGate(probe, BEFORE_NARROW_WINDOW); | |
| 1060 | yield* makeGate(hold, BEFORE_NARROW_WINDOW); | |
| 1061 | yield* Effect.gen(function* () { | |
| 1062 | const service = yield* UsageService.make; | |
| 1063 | const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); | |
| 1064 | yield* openGate(probe); | |
| 1065 | yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); | |
| 1066 | // The narrow scan caches the newer read while the wide one waits. | |
| 1067 | yield* service.readSummary(NARROW_WINDOW); | |
| 1068 | yield* openGate(hold); | |
| 1069 | yield* Fiber.join(wide); | |
| 1070 | ||
| 1071 | yield* Effect.promise(() => | |
| 1072 | Promise.all([transcript, probe, hold].map((path) => NodeFSP.rm(path))), | |
| 1073 | ); | |
| 1074 | assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); | |
| 1075 | }).pipe( | |
| 1076 | Effect.provide( | |
| 1077 | serviceLayers({ prefix: "usage-service-stale-read-test", home, settings }), | |
| 1078 | ), | |
| 1079 | ); | |
| 1080 | }).pipe(Effect.scoped), | |
| 1081 | ); | |
| 1082 | ||
| 1083 | it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( | |
| 1084 | "keeps the later read when a scan that read earlier finishes first", | |
| 1085 | () => | |
| 1086 | Effect.gen(function* () { | |
| 1087 | const { transcript, settings, home } = yield* setup; | |
| 1088 | const dir = NodePath.dirname(transcript); | |
| 1089 | const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); | |
| 1090 | const wideProbe = gate("wide-probe"); | |
| 1091 | const wideHold = gate("wide-hold"); | |
| 1092 | const narrowProbe = gate("narrow-probe"); | |
| 1093 | const narrowHold = gate("narrow-hold"); | |
| 1094 | yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); | |
| 1095 | yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); | |
| 1096 | yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); | |
| 1097 | yield* Effect.gen(function* () { | |
| 1098 | const service = yield* UsageService.make; | |
| 1099 | const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); | |
| 1100 | yield* openGate(wideProbe); | |
| 1101 | yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); | |
| 1102 | // Made after the wide scan's walk, so only the narrow scan waits on them. | |
| 1103 | yield* makeGate(narrowProbe); | |
| 1104 | yield* makeGate(narrowHold); | |
| 1105 | const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); | |
| 1106 | yield* openGate(narrowProbe); | |
| 1107 | // Both scans started from an empty cache entry; the earlier read lands first. | |
| 1108 | yield* openGate(wideHold); | |
| 1109 | yield* Fiber.join(wide); | |
| 1110 | yield* openGate(narrowHold); | |
| 1111 | yield* Fiber.join(narrow); | |
| 1112 | ||
| 1113 | yield* Effect.promise(() => | |
| 1114 | Promise.all( | |
| 1115 | [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => | |
| 1116 | NodeFSP.rm(path), | |
| 1117 | ), | |
| 1118 | ), | |
| 1119 | ); | |
| 1120 | assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); | |
| 1121 | }).pipe( | |
| 1122 | Effect.provide(serviceLayers({ prefix: "usage-service-late-read-test", home, settings })), | |
| 1123 | ); | |
| 1124 | }).pipe(Effect.scoped), | |
| 1125 | ); | |
| 1126 | ||
| 1127 | it.live("reports saved usage of a removed directory only for windows it reaches", () => | |
| 1128 | Effect.gen(function* () { | |
| 1129 | const { transcript, settings, home } = yield* setup; | |
| 1130 | yield* Effect.promise(async () => { | |
| 1131 | await NodeFSP.writeFile(transcript, claudeLine(1, 5)); | |
| 1132 | const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; | |
| 1133 | await NodeFSP.utimes(transcript, lastWrite, lastWrite); | |
| 1134 | }); | |
| 1135 | const service = yield* UsageService.make.pipe( | |
| 1136 | Effect.provide( | |
| 1137 | serviceLayers({ prefix: "usage-service-saved-window-test", home, settings }), | |
| 1138 | ), | |
| 1139 | ); | |
| 1140 | const first = yield* service.readSummary(WINDOW); | |
| 1141 | yield* Effect.promise(() => | |
| 1142 | NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), | |
| 1143 | ); | |
| 1144 | ||
| 1145 | const reached = yield* service.readSummary(WINDOW); | |
| 1146 | assert.deepStrictEqual(reached.buckets, first.buckets); | |
| 1147 | assert.strictEqual(reached.sources[0]?.status, "ok"); | |
| 1148 | ||
| 1149 | // A missing source cannot claim this directory from another environment | |
| 1150 | // that still reads it, so it adds nothing to a window after its last write. | |
| 1151 | const later = yield* service.readSummary({ | |
| 1152 | timeZone: "UTC", | |
| 1153 | sinceDay: UsageDay.make("2026-08-10"), | |
| 1154 | untilDay: UsageDay.make("2026-08-12"), | |
| 1155 | }); | |
| 1156 | assert.deepStrictEqual(later.buckets, []); | |
| 1157 | assert.strictEqual(later.sources[0]?.status, "missing"); | |
| 1158 | assert.strictEqual(later.sources[0]?.scannedFiles, 0); | |
| 1159 | }).pipe(Effect.scoped), | |
| 1160 | ); | |
| 1161 | ||
| 975 | 1162 | it.live("does not share an in-flight scan after custom prices change", () => |
| 976 | 1163 | Effect.gen(function* () { |
| 977 | 1164 | const { transcript, settings, home } = yield* setup; |
apps/server/src/usage/UsageService.ts+122-47| ... | ... | @@ -63,10 +63,11 @@ import { |
| 63 | 63 | import { |
| 64 | 64 | decodeScanCache, |
| 65 | 65 | dedupeWithinFile, |
| 66 | encodeScanCache, | |
| 67 | 66 | LEGACY_SCAN_CACHE_FILE_NAME, |
| 67 | makeScanCacheWriter, | |
| 68 | 68 | pruneScanCache, |
| 69 | 69 | SCAN_CACHE_FILE_NAME, |
| 70 | type CachedFile, | |
| 70 | 71 | type ScanCache, |
| 71 | 72 | } from "./usageScanCache.ts"; |
| 72 | 73 | import type { UsageRecord } from "./usageTranscripts.ts"; |
| ... | ... | @@ -90,6 +91,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; |
| 90 | 91 | /** Longest window the UI offers, plus slack. Older entries are pruned. */ |
| 91 | 92 | const CACHE_RETENTION_DAYS = 90; |
| 92 | 93 | |
| 94 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ | |
| 95 | const TRANSCRIPT_READ_CONCURRENCY = 4; | |
| 96 | ||
| 93 | 97 | const decodeCodexSettings = Schema.decodeOption(CodexSettings); |
| 94 | 98 | const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); |
| 95 | 99 | |
| ... | ... | @@ -108,9 +112,37 @@ const encodeRatesCache = Schema.encodeEffect( |
| 108 | 112 | /** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ |
| 109 | 113 | const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>); |
| 110 | 114 | const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); |
| 111 | const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); | |
| 112 | 115 | const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); |
| 113 | 116 | const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); |
| 117 | ||
| 118 | /** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ | |
| 119 | function isLaterRead(a: CachedFile, b: CachedFile): boolean { | |
| 120 | return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Codex sessions with records in more than one file, such as a rollout that | |
| 125 | * moved after it was read. Only these need cross-file dedupe keys: within one | |
| 126 | * file the occurrence count already keeps every key unique, so keying the rest | |
| 127 | * would only build and hash a string for each of their records. | |
| 128 | */ | |
| 129 | function sharedCodexSessions( | |
| 130 | files: readonly { readonly records: readonly UsageRecord[] }[], | |
| 131 | ): ReadonlySet<string> { | |
| 132 | const firstFile = new Map<string, number>(); | |
| 133 | const shared = new Set<string>(); | |
| 134 | for (const [index, file] of files.entries()) { | |
| 135 | let previous = ""; | |
| 136 | for (const { provider, sessionId } of file.records) { | |
| 137 | if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; | |
| 138 | previous = sessionId; | |
| 139 | const first = firstFile.get(sessionId); | |
| 140 | if (first === undefined) firstFile.set(sessionId, index); | |
| 141 | else if (first !== index) shared.add(sessionId); | |
| 142 | } | |
| 143 | } | |
| 144 | return shared; | |
| 145 | } | |
| 114 | 146 | const decodeCachedSources = Schema.decodeUnknownOption( |
| 115 | 147 | Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), |
| 116 | 148 | ); |
| ... | ... | @@ -386,20 +418,28 @@ export const make = Effect.gen(function* () { |
| 386 | 418 | }), |
| 387 | 419 | ); |
| 388 | 420 | |
| 421 | const writeScanCache = makeScanCacheWriter(); | |
| 422 | // Scans with different windows can finish together; two writes interleaved | |
| 423 | // in one file would corrupt it. | |
| 424 | const persistLock = yield* Semaphore.make(1); | |
| 425 | ||
| 389 | 426 | const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { |
| 390 | 427 | if (!cacheDirty) return; |
| 391 | // Cleared only after the write lands, so a failed persist is retried on | |
| 392 | // the next scan instead of leaving disk permanently stale. | |
| 393 | yield* encodeScanCacheFile({ | |
| 394 | ...encodeScanCache(fileCache), | |
| 395 | sources: Object.fromEntries(sourceCache), | |
| 396 | }).pipe( | |
| 428 | // Cleared before encoding, so a scan that changes the cache while this | |
| 429 | // write is in flight marks it dirty again. A failed write restores the | |
| 430 | // flag, so the next scan retries instead of leaving disk stale. | |
| 431 | cacheDirty = false; | |
| 432 | yield* Effect.sync(() => | |
| 433 | writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), | |
| 434 | ).pipe( | |
| 397 | 435 | Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), |
| 398 | Effect.map(() => { | |
| 399 | cacheDirty = false; | |
| 400 | }), | |
| 401 | 436 | // A cache we cannot write is a slower next start, not a failed read. |
| 402 | Effect.ignoreCause, | |
| 437 | Effect.catchCause(() => | |
| 438 | Effect.sync(() => { | |
| 439 | cacheDirty = true; | |
| 440 | }), | |
| 441 | ), | |
| 442 | persistLock.withPermit, | |
| 403 | 443 | ); |
| 404 | 444 | }); |
| 405 | 445 | |
| ... | ... | @@ -410,13 +450,22 @@ export const make = Effect.gen(function* () { |
| 410 | 450 | * written multi-hundred-megabyte rollout costs its appended bytes per scan |
| 411 | 451 | * rather than a full re-read. The reader verifies the position's guard bytes |
| 412 | 452 | * and silently restarts from byte 0 when they no longer match. |
| 453 | * | |
| 454 | * A fresh parse comes back as `update` for the caller to cache, with the | |
| 455 | * entry it was built from. Reads run concurrently, and the caller stores | |
| 456 | * updates in walk order rather than completion order: saved records of | |
| 457 | * deleted transcripts aggregate in cache order, where the first copy of a | |
| 458 | * duplicate wins. | |
| 413 | 459 | */ |
| 414 | 460 | const readFileRecords = ( |
| 415 | 461 | filePath: string, |
| 416 | 462 | size: number, |
| 417 | 463 | mtimeMs: number, |
| 418 | 464 | provider: UsageProviderKind, |
| 419 | ): Effect.Effect<readonly UsageRecord[]> => | |
| 465 | ): Effect.Effect<{ | |
| 466 | readonly records: readonly UsageRecord[]; | |
| 467 | readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; | |
| 468 | }> => | |
| 420 | 469 | Effect.gen(function* () { |
| 421 | 470 | const cached = fileCache.get(filePath); |
| 422 | 471 | // Provider is part of the identity: if both providers were ever pointed |
| ... | ... | @@ -427,9 +476,12 @@ export const make = Effect.gen(function* () { |
| 427 | 476 | cached.mtimeMs === mtimeMs && |
| 428 | 477 | cached.provider === provider |
| 429 | 478 | ) { |
| 430 | return cached.tailRecords.length === 0 | |
| 431 | ? cached.records | |
| 432 | : [...cached.records, ...cached.tailRecords]; | |
| 479 | return { | |
| 480 | records: | |
| 481 | cached.tailRecords.length === 0 | |
| 482 | ? cached.records | |
| 483 | : [...cached.records, ...cached.tailRecords], | |
| 484 | }; | |
| 433 | 485 | } |
| 434 | 486 | |
| 435 | 487 | // Only a strictly grown file may resume. Same size with a new mtime, or |
| ... | ... | @@ -445,7 +497,9 @@ export const make = Effect.gen(function* () { |
| 445 | 497 | // A read failure is not an empty transcript: caching it under this |
| 446 | 498 | // (size, mtime) would silently drop the file's usage until it changes. |
| 447 | 499 | if (parsed === null) |
| 448 | return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; | |
| 500 | return { | |
| 501 | records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], | |
| 502 | }; | |
| 449 | 503 | |
| 450 | 504 | // Stored already de-duplicated within the file, which is 99% of all |
| 451 | 505 | // duplicates. The aggregator still runs the cross-file dedupe pass. One |
| ... | ... | @@ -456,16 +510,13 @@ export const make = Effect.gen(function* () { |
| 456 | 510 | const records = dedupeWithinFile([...base, ...parsed.records], seen); |
| 457 | 511 | const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); |
| 458 | 512 | |
| 459 | fileCache.set(filePath, { | |
| 460 | size, | |
| 461 | mtimeMs, | |
| 462 | provider, | |
| 463 | records, | |
| 464 | tailRecords, | |
| 465 | position: parsed.position, | |
| 466 | }); | |
| 467 | cacheDirty = true; | |
| 468 | return tailRecords.length === 0 ? records : [...records, ...tailRecords]; | |
| 513 | return { | |
| 514 | records: tailRecords.length === 0 ? records : [...records, ...tailRecords], | |
| 515 | update: { | |
| 516 | entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, | |
| 517 | replaces: cached, | |
| 518 | }, | |
| 519 | }; | |
| 469 | 520 | }); |
| 470 | 521 | |
| 471 | 522 | /** One provider directory's walk and parse, before rates are involved. */ |
| ... | ... | @@ -505,11 +556,32 @@ export const make = Effect.gen(function* () { |
| 505 | 556 | const files = yield* Effect.promise(() => |
| 506 | 557 | listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), |
| 507 | 558 | ); |
| 508 | const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; | |
| 509 | for (const file of files) { | |
| 510 | const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); | |
| 511 | parsedFiles.push({ path: file.path, records }); | |
| 512 | } | |
| 559 | // A cold parse waits on disk reads, so a few files in flight read | |
| 560 | // close to twice as fast. Results keep walk order. | |
| 561 | const read = yield* Effect.forEach( | |
| 562 | files, | |
| 563 | (file) => | |
| 564 | readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( | |
| 565 | Effect.map((result) => ({ path: file.path, ...result })), | |
| 566 | ), | |
| 567 | { concurrency: TRANSCRIPT_READ_CONCURRENCY }, | |
| 568 | ); | |
| 569 | const parsedFiles = read.map(({ path, records, update }) => { | |
| 570 | if (update === undefined) return { path, records }; | |
| 571 | // A scan of another window may have cached its own read of this file | |
| 572 | // meanwhile. Then keep whichever read saw the later file, so a slower | |
| 573 | // scan never replaces newer usage with older. | |
| 574 | const current = fileCache.get(path); | |
| 575 | if ( | |
| 576 | current === update.replaces || | |
| 577 | current === undefined || | |
| 578 | !isLaterRead(current, update.entry) | |
| 579 | ) { | |
| 580 | fileCache.set(path, update.entry); | |
| 581 | cacheDirty = true; | |
| 582 | } | |
| 583 | return { path, records }; | |
| 584 | }); | |
| 513 | 585 | scanned.push({ provider, dir, volumeId, files: parsedFiles }); |
| 514 | 586 | } |
| 515 | 587 | |
| ... | ... | @@ -753,37 +825,39 @@ export const make = Effect.gen(function* () { |
| 753 | 825 | |
| 754 | 826 | const sources: UsageSource[] = []; |
| 755 | 827 | |
| 756 | for (const { | |
| 757 | provider, | |
| 758 | dir, | |
| 759 | volumeId, | |
| 760 | files, | |
| 761 | status, | |
| 762 | message, | |
| 763 | action, | |
| 764 | hostId: sourceHostId, | |
| 765 | } of scannedDirs) { | |
| 828 | // Cleanup may remove transcripts, but the usage we already saved still | |
| 829 | // contributes to its source through the normal aggregation and dedupe | |
| 830 | // path. Like the walk, skip files last written before the window: they | |
| 831 | // cannot hold records inside it. | |
| 832 | const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); | |
| 833 | const filesByDir = scannedDirs.map(({ provider, dir, files }) => { | |
| 766 | 834 | const retainedFiles = [...(files ?? [])]; |
| 767 | 835 | const livePaths = new Set(retainedFiles.map((file) => file.path)); |
| 768 | // Cleanup may remove transcripts, but the usage we already saved still | |
| 769 | // contributes to this source. Keep the normal aggregation and dedupe path. | |
| 770 | 836 | for (const [filePath, entry] of fileCache) { |
| 771 | 837 | if ( |
| 772 | 838 | entry.provider !== provider || |
| 773 | entry.mtimeMs < retentionCutoffMs || | |
| 839 | entry.mtimeMs < retainedSinceMs || | |
| 774 | 840 | livePaths.has(filePath) || |
| 775 | 841 | !isWithinDirectory(filePath, dir) |
| 776 | 842 | ) |
| 777 | 843 | continue; |
| 778 | 844 | retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); |
| 779 | 845 | } |
| 846 | return retainedFiles; | |
| 847 | }); | |
| 848 | const sharedSessions = sharedCodexSessions(filesByDir.flat()); | |
| 849 | ||
| 850 | for (const [ | |
| 851 | index, | |
| 852 | { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, | |
| 853 | ] of scannedDirs.entries()) { | |
| 780 | 854 | let scannedFiles = 0; |
| 781 | 855 | let skippedFiles = 0; |
| 782 | 856 | // Distinct per directory. Buckets carry per-cell session counts, but a |
| 783 | 857 | // session spans days and models, so clients total this figure instead. |
| 784 | 858 | const sessionIds = new Set<string>(); |
| 785 | 859 | |
| 786 | for (const file of retainedFiles) { | |
| 860 | for (const file of filesByDir[index] ?? []) { | |
| 787 | 861 | if (file.records.length === 0) { |
| 788 | 862 | skippedFiles += 1; |
| 789 | 863 | continue; |
| ... | ... | @@ -792,9 +866,10 @@ export const make = Effect.gen(function* () { |
| 792 | 866 | const codexEventOccurrences = new Map<string, number>(); |
| 793 | 867 | for (const record of file.records) { |
| 794 | 868 | let usageRecord = record; |
| 795 | if (record.provider === "codex" && record.sessionId.length > 0) { | |
| 869 | if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { | |
| 796 | 870 | // Match moved rollout copies without collapsing repeated equal events |
| 797 | 871 | // within one rollout (timestamps can have only second precision). |
| 872 | // Only sessions seen in several files can have a copy to match. | |
| 798 | 873 | const key = encodeUsageRecordKey([ |
| 799 | 874 | record.provider, |
| 800 | 875 | record.sessionId, |
apps/server/src/usage/usageAggregation.test.ts+35| ... | ... | @@ -128,6 +128,41 @@ describe("UsageAggregator", () => { |
| 128 | 128 | expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | it("finds the day at a quarter-hour zone's midnight across interleaved buckets", () => { | |
| 132 | // Kathmandu is UTC+5:45, so its midnight falls at 18:15 UTC. | |
| 133 | const result = aggregate( | |
| 134 | [ | |
| 135 | record({ timestampMs: Date.parse("2026-08-01T18:14:59.999Z") }), | |
| 136 | record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z") }), | |
| 137 | record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z"), model: "claude-opus-5" }), | |
| 138 | record({ timestampMs: Date.parse("2026-08-01T18:16:00.000Z") }), | |
| 139 | ], | |
| 140 | "Asia/Kathmandu", | |
| 141 | ); | |
| 142 | ||
| 143 | expect(result.buckets.map((bucket) => [bucket.day, bucket.model, bucket.records])).toEqual([ | |
| 144 | ["2026-08-01", "claude-fable-5", 1], | |
| 145 | ["2026-08-02", "claude-fable-5", 2], | |
| 146 | ["2026-08-02", "claude-opus-5", 1], | |
| 147 | ]); | |
| 148 | }); | |
| 149 | ||
| 150 | it("finds the day at a fixed offset's midnight between quarter hours", () => { | |
| 151 | // At +00:01, midnight falls at 23:59 UTC. | |
| 152 | const result = aggregate( | |
| 153 | [ | |
| 154 | record({ timestampMs: Date.parse("2026-08-01T23:58:59.999Z") }), | |
| 155 | record({ timestampMs: Date.parse("2026-08-01T23:59:00.000Z") }), | |
| 156 | ], | |
| 157 | "+00:01", | |
| 158 | ); | |
| 159 | ||
| 160 | expect(result.buckets.map((bucket) => [bucket.day, bucket.records])).toEqual([ | |
| 161 | ["2026-08-01", 1], | |
| 162 | ["2026-08-02", 1], | |
| 163 | ]); | |
| 164 | }); | |
| 165 | ||
| 131 | 166 | it("splits an hourly request into fixed buckets anchored to its exact start", () => { |
| 132 | 167 | const result = aggregate( |
| 133 | 168 | [ |
apps/server/src/usage/usageAggregation.ts+87-29| ... | ... | @@ -20,7 +20,7 @@ import type { |
| 20 | 20 | UsageTokenTotals, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | 22 | |
| 23 | import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; | |
| 23 | import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; | |
| 24 | 24 | import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; |
| 25 | 25 | |
| 26 | 26 | /** |
| ... | ... | @@ -47,13 +47,30 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { |
| 47 | 47 | day: "2-digit", |
| 48 | 48 | }); |
| 49 | 49 | } |
| 50 | return (timestampMs) => format.format(new Date(timestampMs)); | |
| 50 | // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off | |
| 51 | // the per-record path. A slot whose two ends fall on one day lies wholly in | |
| 52 | // that day. Named zones put midnight on a quarter hour, so their slots never | |
| 53 | // split; a fixed offset such as `+00:01` can, and records in a split slot are | |
| 54 | // formatted exactly. `null` marks a split slot. | |
| 55 | const days = new Map<number, string | null>(); | |
| 56 | return (timestampMs) => { | |
| 57 | const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); | |
| 58 | let day = days.get(slot); | |
| 59 | if (day === undefined) { | |
| 60 | const first = format.format(new Date(slot * QUARTER_HOUR_MS)); | |
| 61 | const last = format.format(new Date((slot + 1) * QUARTER_HOUR_MS - 1)); | |
| 62 | day = first === last ? first : null; | |
| 63 | days.set(slot, day); | |
| 64 | } | |
| 65 | return day ?? format.format(new Date(timestampMs)); | |
| 66 | }; | |
| 51 | 67 | } |
| 52 | 68 | |
| 69 | const QUARTER_HOUR_MS = 15 * 60 * 1000; | |
| 53 | 70 | const HOUR_MS = 60 * 60 * 1000; |
| 54 | 71 | |
| 55 | 72 | interface MutableBucket { |
| 56 | totals: UsageTokenTotals; | |
| 73 | totals: { -readonly [K in keyof UsageTokenTotals]: number }; | |
| 57 | 74 | costUsd: number; |
| 58 | 75 | cacheSavingsUsd: number; |
| 59 | 76 | categoryCostUsd: UsageCategoryCost | null; |
| ... | ... | @@ -98,6 +115,14 @@ export class UsageAggregator { |
| 98 | 115 | readonly #toDay: (timestampMs: number) => string; |
| 99 | 116 | readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; |
| 100 | 117 | readonly #options: AggregateOptions; |
| 118 | #lastBucket: { | |
| 119 | readonly day: string; | |
| 120 | readonly hourIndex: number; | |
| 121 | readonly provider: string; | |
| 122 | readonly model: string; | |
| 123 | readonly source: string; | |
| 124 | readonly bucket: MutableBucket; | |
| 125 | } | null = null; | |
| 101 | 126 | #duplicatesDropped = 0; |
| 102 | 127 | #outOfWindow = 0; |
| 103 | 128 | |
| ... | ... | @@ -149,35 +174,20 @@ export class UsageAggregator { |
| 149 | 174 | return false; |
| 150 | 175 | } |
| 151 | 176 | |
| 152 | const hourStart = | |
| 177 | const hourIndex = | |
| 153 | 178 | this.#hourlyWindow === null |
| 154 | ? "" | |
| 155 | : new Date( | |
| 156 | this.#hourlyWindow.sinceTimeMs + | |
| 157 | Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, | |
| 158 | ).toISOString(); | |
| 159 | const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; | |
| 160 | let bucket = this.#buckets.get(key); | |
| 161 | if (bucket === undefined) { | |
| 162 | bucket = { | |
| 163 | totals: EMPTY_TOTALS, | |
| 164 | costUsd: 0, | |
| 165 | cacheSavingsUsd: 0, | |
| 166 | categoryCostUsd: null, | |
| 167 | fastCostUsd: 0, | |
| 168 | ultrafastCostUsd: 0, | |
| 169 | speedPremiumUsd: 0, | |
| 170 | records: 0, | |
| 171 | unpricedRecords: 0, | |
| 172 | providerReportedRecords: 0, | |
| 173 | sessions: new Set<string>(), | |
| 174 | }; | |
| 175 | this.#buckets.set(key, bucket); | |
| 176 | } | |
| 179 | ? -1 | |
| 180 | : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); | |
| 181 | const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); | |
| 177 | 182 | |
| 178 | 183 | const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); |
| 179 | 184 | |
| 180 | bucket.totals = addTotals(bucket.totals, record.totals); | |
| 185 | const totals = bucket.totals; | |
| 186 | totals.uncachedInputTokens += record.totals.uncachedInputTokens; | |
| 187 | totals.cachedInputTokens += record.totals.cachedInputTokens; | |
| 188 | totals.cacheCreationTokens += record.totals.cacheCreationTokens; | |
| 189 | totals.outputTokens += record.totals.outputTokens; | |
| 190 | totals.reasoningTokens += record.totals.reasoningTokens; | |
| 181 | 191 | bucket.costUsd += priced.costUsd; |
| 182 | 192 | if (priced.categoryCostUsd !== null) { |
| 183 | 193 | const sum = bucket.categoryCostUsd; |
| ... | ... | @@ -207,6 +217,54 @@ export class UsageAggregator { |
| 207 | 217 | return true; |
| 208 | 218 | } |
| 209 | 219 | |
| 220 | /** | |
| 221 | * Records arrive file by file in time order, so most land in the bucket the | |
| 222 | * previous record used. Checking that first skips building and hashing a | |
| 223 | * key string per record. | |
| 224 | */ | |
| 225 | #bucketFor( | |
| 226 | day: string, | |
| 227 | hourIndex: number, | |
| 228 | provider: string, | |
| 229 | model: string, | |
| 230 | source: string, | |
| 231 | ): MutableBucket { | |
| 232 | const last = this.#lastBucket; | |
| 233 | if ( | |
| 234 | last !== null && | |
| 235 | last.day === day && | |
| 236 | last.hourIndex === hourIndex && | |
| 237 | last.provider === provider && | |
| 238 | last.model === model && | |
| 239 | last.source === source | |
| 240 | ) { | |
| 241 | return last.bucket; | |
| 242 | } | |
| 243 | const window = this.#hourlyWindow; | |
| 244 | const hourStart = | |
| 245 | window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString(); | |
| 246 | const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`; | |
| 247 | let bucket = this.#buckets.get(key); | |
| 248 | if (bucket === undefined) { | |
| 249 | bucket = { | |
| 250 | totals: { ...EMPTY_TOTALS }, | |
| 251 | costUsd: 0, | |
| 252 | cacheSavingsUsd: 0, | |
| 253 | categoryCostUsd: null, | |
| 254 | fastCostUsd: 0, | |
| 255 | ultrafastCostUsd: 0, | |
| 256 | speedPremiumUsd: 0, | |
| 257 | records: 0, | |
| 258 | unpricedRecords: 0, | |
| 259 | providerReportedRecords: 0, | |
| 260 | sessions: new Set<string>(), | |
| 261 | }; | |
| 262 | this.#buckets.set(key, bucket); | |
| 263 | } | |
| 264 | this.#lastBucket = { day, hourIndex, provider, model, source, bucket }; | |
| 265 | return bucket; | |
| 266 | } | |
| 267 | ||
| 210 | 268 | finish(): AggregateResult { |
| 211 | 269 | const buckets: UsageBucket[] = []; |
| 212 | 270 | for (const [key, bucket] of this.#buckets) { |
| ... | ... | @@ -222,7 +280,7 @@ export class UsageAggregator { |
| 222 | 280 | provider: provider as UsageBucket["provider"], |
| 223 | 281 | model, |
| 224 | 282 | ...(sourcePath === "" ? {} : { sourcePath }), |
| 225 | totals: bucket.totals, | |
| 283 | totals: { ...bucket.totals }, | |
| 226 | 284 | costUsd: bucket.costUsd, |
| 227 | 285 | cacheSavingsUsd: bucket.cacheSavingsUsd, |
| 228 | 286 | // Zero and unknown figures are omitted to keep payloads small. |
apps/server/src/usage/usagePricing.ts+20| ... | ... | @@ -235,7 +235,27 @@ const UNPRICEABLE_MODELS = new Set([ |
| 235 | 235 | "fable", |
| 236 | 236 | ]); |
| 237 | 237 | |
| 238 | /** | |
| 239 | * Lookups per table, by raw model name. A scan prices every record twice | |
| 240 | * against a few dozen models, and tables are never mutated once built. | |
| 241 | */ | |
| 242 | const resolvedRates = new WeakMap<RateTable, Map<string, ModelRate | null>>(); | |
| 243 | ||
| 238 | 244 | export function lookupRate(table: RateTable, model: string): ModelRate | null { |
| 245 | let resolved = resolvedRates.get(table); | |
| 246 | if (resolved === undefined) { | |
| 247 | resolved = new Map(); | |
| 248 | resolvedRates.set(table, resolved); | |
| 249 | } | |
| 250 | let rate = resolved.get(model); | |
| 251 | if (rate === undefined) { | |
| 252 | rate = resolveRate(table, model); | |
| 253 | resolved.set(model, rate); | |
| 254 | } | |
| 255 | return rate; | |
| 256 | } | |
| 257 | ||
| 258 | function resolveRate(table: RateTable, model: string): ModelRate | null { | |
| 239 | 259 | const key = stripVariantSuffix(normalizeRateKey(model)); |
| 240 | 260 | const bareName = bareModelName(key); |
| 241 | 261 | if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; |
apps/server/src/usage/usageScanCache.test.ts+21| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | decodeScanCache, |
| 5 | 5 | dedupeWithinFile, |
| 6 | 6 | encodeScanCache, |
| 7 | makeScanCacheWriter, | |
| 7 | 8 | pruneScanCache, |
| 8 | 9 | type CachedFile, |
| 9 | 10 | type ScanCache, |
| ... | ... | @@ -149,6 +150,26 @@ describe("scan cache round trip", () => { |
| 149 | 150 | expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); |
| 150 | 151 | }); |
| 151 | 152 | |
| 153 | it("rewrites only changed entries and still restores the whole cache", () => { | |
| 154 | const write = makeScanCacheWriter(); | |
| 155 | const cache = cacheWith([ | |
| 156 | ["/a.jsonl", 100, [record()]], | |
| 157 | ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], | |
| 158 | ]); | |
| 159 | const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; | |
| 160 | expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); | |
| 161 | ||
| 162 | // The replacement adds intern entries; /a's memoised indexes must hold. | |
| 163 | cache.set("/b.jsonl", { | |
| 164 | ...cache.get("/b.jsonl")!, | |
| 165 | size: 30, | |
| 166 | records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], | |
| 167 | }); | |
| 168 | const document = JSON.parse(write(cache, { sources })); | |
| 169 | expect(decodeScanCache(document)).toEqual(cache); | |
| 170 | expect(document.sources).toEqual(sources); | |
| 171 | }); | |
| 172 | ||
| 152 | 173 | it("interns repeated model and session strings", () => { |
| 153 | 174 | const encoded = encodeScanCache( |
| 154 | 175 | cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), |
apps/server/src/usage/usageScanCache.ts+81-30| ... | ... | @@ -103,26 +103,32 @@ interface SerializedCache { |
| 103 | 103 | readonly files: Readonly<Record<string, SerializedFile>>; |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | /** Serialises the cache, interning the repeated model and session strings. */ | |
| 107 | export function encodeScanCache(cache: ScanCache): SerializedCache { | |
| 108 | const models: string[] = []; | |
| 109 | const sessions: string[] = []; | |
| 110 | const modelIndex = new Map<string, number>(); | |
| 111 | const sessionIndex = new Map<string, number>(); | |
| 106 | /** Model and session strings, each stored once and referenced by index. */ | |
| 107 | interface InternTables { | |
| 108 | readonly models: string[]; | |
| 109 | readonly sessions: string[]; | |
| 110 | readonly modelIndex: Map<string, number>; | |
| 111 | readonly sessionIndex: Map<string, number>; | |
| 112 | } | |
| 112 | 113 | |
| 113 | const intern = (table: string[], index: Map<string, number>, value: string): number => { | |
| 114 | const existing = index.get(value); | |
| 115 | if (existing !== undefined) return existing; | |
| 116 | const next = table.length; | |
| 117 | table.push(value); | |
| 118 | index.set(value, next); | |
| 119 | return next; | |
| 120 | }; | |
| 114 | function makeInternTables(): InternTables { | |
| 115 | return { models: [], sessions: [], modelIndex: new Map(), sessionIndex: new Map() }; | |
| 116 | } | |
| 117 | ||
| 118 | function intern(table: string[], index: Map<string, number>, value: string): number { | |
| 119 | const existing = index.get(value); | |
| 120 | if (existing !== undefined) return existing; | |
| 121 | const next = table.length; | |
| 122 | table.push(value); | |
| 123 | index.set(value, next); | |
| 124 | return next; | |
| 125 | } | |
| 121 | 126 | |
| 127 | function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile { | |
| 122 | 128 | const serializeRecord = (record: UsageRecord): SerializedRecord => [ |
| 123 | 129 | record.timestampMs, |
| 124 | intern(models, modelIndex, record.model), | |
| 125 | intern(sessions, sessionIndex, record.sessionId), | |
| 130 | intern(tables.models, tables.modelIndex, record.model), | |
| 131 | intern(tables.sessions, tables.sessionIndex, record.sessionId), | |
| 126 | 132 | record.totals.uncachedInputTokens, |
| 127 | 133 | record.totals.cachedInputTokens, |
| 128 | 134 | record.totals.cacheCreationTokens, |
| ... | ... | @@ -132,23 +138,68 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { |
| 132 | 138 | record.reportedCostUsd, |
| 133 | 139 | SPEEDS.indexOf(record.speed), |
| 134 | 140 | ]; |
| 141 | return { | |
| 142 | s: entry.size, | |
| 143 | m: entry.mtimeMs, | |
| 144 | p: entry.provider, | |
| 145 | r: entry.records.map(serializeRecord), | |
| 146 | t: entry.tailRecords.map(serializeRecord), | |
| 147 | o: entry.position.resumeOffset, | |
| 148 | gl: entry.position.guardLength, | |
| 149 | gh: entry.position.guardHash, | |
| 150 | cs: entry.position.codexState, | |
| 151 | }; | |
| 152 | } | |
| 135 | 153 | |
| 154 | /** Serialises the cache, interning the repeated model and session strings. */ | |
| 155 | export function encodeScanCache(cache: ScanCache): SerializedCache { | |
| 156 | const tables = makeInternTables(); | |
| 136 | 157 | const files: Record<string, SerializedFile> = {}; |
| 137 | for (const [path, entry] of cache) { | |
| 138 | files[path] = { | |
| 139 | s: entry.size, | |
| 140 | m: entry.mtimeMs, | |
| 141 | p: entry.provider, | |
| 142 | r: entry.records.map(serializeRecord), | |
| 143 | t: entry.tailRecords.map(serializeRecord), | |
| 144 | o: entry.position.resumeOffset, | |
| 145 | gl: entry.position.guardLength, | |
| 146 | gh: entry.position.guardHash, | |
| 147 | cs: entry.position.codexState, | |
| 148 | }; | |
| 149 | } | |
| 158 | for (const [path, entry] of cache) files[path] = serializeFile(entry, tables); | |
| 159 | return { | |
| 160 | version: USAGE_SCAN_CACHE_VERSION, | |
| 161 | models: tables.models, | |
| 162 | sessions: tables.sessions, | |
| 163 | files, | |
| 164 | }; | |
| 165 | } | |
| 150 | 166 | |
| 151 | return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; | |
| 167 | /** | |
| 168 | * Returns a function that serialises the cache to JSON text, re-encoding only | |
| 169 | * the entries that changed since its last call. Call it once per persist. | |
| 170 | * | |
| 171 | * Writes the same document as `encodeScanCache`. Most entries never change | |
| 172 | * between scans, and encoding all of them made each persist cost close to a | |
| 173 | * second on a large cache. Entries are replaced, never mutated, when their file | |
| 174 | * changes, so an entry's JSON is memoised by identity. The intern tables only | |
| 175 | * grow, so a memoised entry's indexes stay valid; a pruned entry can leave an | |
| 176 | * unused string behind until the next process start. | |
| 177 | */ | |
| 178 | export function makeScanCacheWriter(): ( | |
| 179 | cache: ScanCache, | |
| 180 | extra: Readonly<Record<string, unknown>>, | |
| 181 | ) => string { | |
| 182 | const tables = makeInternTables(); | |
| 183 | const fragments = new WeakMap<CachedFile, string>(); | |
| 184 | return (cache, extra) => { | |
| 185 | const files: string[] = []; | |
| 186 | for (const [path, entry] of cache) { | |
| 187 | let fragment = fragments.get(entry); | |
| 188 | if (fragment === undefined) { | |
| 189 | fragment = JSON.stringify(serializeFile(entry, tables)); | |
| 190 | fragments.set(entry, fragment); | |
| 191 | } | |
| 192 | files.push(`${JSON.stringify(path)}:${fragment}`); | |
| 193 | } | |
| 194 | // Encoded after the files, which may have added to the intern tables. | |
| 195 | const head = JSON.stringify({ | |
| 196 | ...extra, | |
| 197 | version: USAGE_SCAN_CACHE_VERSION, | |
| 198 | models: tables.models, | |
| 199 | sessions: tables.sessions, | |
| 200 | }); | |
| 201 | return `${head.slice(0, -1)},"files":{${files.join(",")}}}`; | |
| 202 | }; | |
| 152 | 203 | } |
| 153 | 204 | |
| 154 | 205 | function isRecordArray(value: unknown): value is readonly unknown[] { |
apps/server/src/usage/usageTranscriptReader.ts+26-15| ... | ... | @@ -85,6 +85,8 @@ export const GUARD_LENGTH = 64; |
| 85 | 85 | // readers; it never discards a record because of its size. |
| 86 | 86 | const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; |
| 87 | 87 | const NEWLINE = 0x0a; |
| 88 | /** `stat` calls one transcript walk keeps in flight. The libuv pool has 4 threads. */ | |
| 89 | const STAT_CONCURRENCY = 32; | |
| 88 | 90 | const CARRIAGE_RETURN = 0x0d; |
| 89 | 91 | |
| 90 | 92 | type SelectedFields = { readonly [key: string]: true | SelectedFields }; |
| ... | ... | @@ -156,15 +158,19 @@ function fnv1a(buffer: Buffer): number { |
| 156 | 158 | * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). |
| 157 | 159 | * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that |
| 158 | 160 | * never carry usage, so the basename filter keeps a cold scan off those files. |
| 161 | * | |
| 162 | * Directories are listed depth-first, one at a time, then the candidates are | |
| 163 | * stat'd by a fixed pool of workers: a warm scan stats thousands of files, and | |
| 164 | * one at a time each waits its own trip through the thread pool. Results keep | |
| 165 | * `readdir` order, which the aggregator's first-seen dedupe relies on. | |
| 159 | 166 | */ |
| 160 | 167 | export async function listTranscriptFiles( |
| 161 | 168 | root: string, |
| 162 | 169 | sinceMs: number, |
| 163 | 170 | options?: { readonly fileName?: string }, |
| 164 | 171 | ): Promise<readonly TranscriptFile[]> { |
| 165 | const found: TranscriptFile[] = []; | |
| 166 | 172 | const fileName = options?.fileName; |
| 167 | ||
| 173 | const candidates: string[] = []; | |
| 168 | 174 | const walk = async (dir: string): Promise<void> => { |
| 169 | 175 | let entries; |
| 170 | 176 | try { |
| ... | ... | @@ -174,28 +180,33 @@ export async function listTranscriptFiles( |
| 174 | 180 | } |
| 175 | 181 | for (const entry of entries) { |
| 176 | 182 | const child = NodePath.join(dir, entry.name); |
| 177 | if (entry.isDirectory()) { | |
| 178 | await walk(child); | |
| 179 | continue; | |
| 180 | } | |
| 181 | if (fileName !== undefined) { | |
| 182 | if (entry.name !== fileName) continue; | |
| 183 | } else if (!entry.name.endsWith(".jsonl")) { | |
| 184 | continue; | |
| 183 | if (entry.isDirectory()) await walk(child); | |
| 184 | else if (fileName !== undefined ? entry.name === fileName : entry.name.endsWith(".jsonl")) { | |
| 185 | candidates.push(child); | |
| 185 | 186 | } |
| 187 | } | |
| 188 | }; | |
| 189 | await walk(root); | |
| 190 | ||
| 191 | const found: Array<TranscriptFile | undefined> = Array.from({ length: candidates.length }); | |
| 192 | // Each worker pulls the next candidate from one shared iterator. | |
| 193 | const queue = candidates.entries(); | |
| 194 | const statQueued = async (): Promise<void> => { | |
| 195 | for (const [index, path] of queue) { | |
| 186 | 196 | try { |
| 187 | const stats = await NodeFSP.stat(child); | |
| 197 | const stats = await NodeFSP.stat(path); | |
| 188 | 198 | if (stats.mtimeMs >= sinceMs) { |
| 189 | found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); | |
| 199 | found[index] = { path, size: stats.size, mtimeMs: stats.mtimeMs }; | |
| 190 | 200 | } |
| 191 | 201 | } catch { |
| 192 | 202 | // Vanished between readdir and stat. |
| 193 | 203 | } |
| 194 | 204 | } |
| 195 | 205 | }; |
| 196 | ||
| 197 | await walk(root); | |
| 198 | return found; | |
| 206 | await Promise.all( | |
| 207 | Array.from({ length: Math.min(STAT_CONCURRENCY, candidates.length) }, statQueued), | |
| 208 | ); | |
| 209 | return found.filter((file) => file !== undefined); | |
| 199 | 210 | } |
| 200 | 211 | |
| 201 | 212 | /** |
apps/server/src/usage/usageTranscripts.ts-10| ... | ... | @@ -54,16 +54,6 @@ function parseTimestampMs(value: unknown): number | null { |
| 54 | 54 | return Number.isNaN(parsed) ? null : parsed; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { | |
| 58 | return { | |
| 59 | uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, | |
| 60 | cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, | |
| 61 | cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, | |
| 62 | outputTokens: a.outputTokens + b.outputTokens, | |
| 63 | reasoningTokens: a.reasoningTokens + b.reasoningTokens, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | 57 | export function totalTokens(totals: UsageTokenTotals): number { |
| 68 | 58 | // reasoningTokens is a subset of outputTokens and must not be added again. |
| 69 | 59 | return ( |