From a7f3e4bc0eebadfaa62c87306855ec19526bf08b Mon Sep 17 00:00:00 2001 From: Tom Price Date: Sat, 11 Jul 2026 13:38:45 -0400 Subject: [PATCH] fix: guard against missing modelBreakdowns in usage aggregation The CCS Bar server was crash-looping with: [X] Cannot read properties of undefined (reading 'length') Usage records from partially-written or legacy persisted files (e.g. a Codex rollout scanned mid-write) can be missing modelBreakdowns despite the type. getHourlyRequestCount() and the daily/monthly/hourly merges read it unguarded, and the Bar re-runs aggregation every ~5 min and restarts on crash - hence the loop. Default modelBreakdowns to [] at every use site and fall back to 0 for request count. Regression test added for all three merges. I don't normally work in TS and this was heavily tool-assisted, but it has fixed the crash loop on my machine. --- src/web-server/usage/aggregator.ts | 17 +-- .../usage-aggregator-malformed-record.test.ts | 106 ++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 tests/unit/web-server/usage-aggregator-malformed-record.test.ts diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 329fa90a..91645ded 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -113,7 +113,8 @@ async function loadInstanceData(instancePath: string): Promise<{ } function getHourlyRequestCount(hour: HourlyUsage): number { - return hour.requestCount ?? hour.modelBreakdowns.length; + // modelBreakdowns can be absent at runtime (legacy snapshots, files read mid-write). + return hour.requestCount ?? hour.modelBreakdowns?.length ?? 0; } function finalizeDailyUsage(day: DailyUsage): DailyUsage { @@ -165,7 +166,7 @@ export function mergeDailyData( existing.cacheReadTokens += day.cacheReadTokens; existing.totalCost += day.totalCost; // Merge model breakdowns by aggregating same modelName - for (const breakdown of day.modelBreakdowns) { + for (const breakdown of day.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (b) => getProviderModelKey(b) === breakdownKey @@ -182,7 +183,7 @@ export function mergeDailyData( } } else { // Clone to avoid mutating original - const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b })); + const modelBreakdowns = (day.modelBreakdowns ?? []).map((b) => ({ ...b })); dateMap.set(mergeKey, { ...day, ...(options.preserveProfile && day.profile ? { profile: day.profile } : {}), @@ -219,7 +220,7 @@ export function mergeMonthlyData( existing.cacheCreationTokens += month.cacheCreationTokens; existing.cacheReadTokens += month.cacheReadTokens; existing.totalCost += month.totalCost; - for (const breakdown of month.modelBreakdowns) { + for (const breakdown of month.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (item) => getProviderModelKey(item) === breakdownKey @@ -235,7 +236,9 @@ export function mergeMonthlyData( } } } else { - const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown })); + const modelBreakdowns = (month.modelBreakdowns ?? []).map((breakdown) => ({ + ...breakdown, + })); monthMap.set(mergeKey, { ...month, ...(options.preserveProfile && month.profile ? { profile: month.profile } : {}), @@ -275,7 +278,7 @@ export function mergeHourlyData( existing.totalCost += hour.totalCost; existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour); // Merge model breakdowns - for (const breakdown of hour.modelBreakdowns) { + for (const breakdown of hour.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (b) => getProviderModelKey(b) === breakdownKey @@ -291,7 +294,7 @@ export function mergeHourlyData( } } } else { - const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b })); + const modelBreakdowns = (hour.modelBreakdowns ?? []).map((b) => ({ ...b })); hourMap.set(mergeKey, { ...hour, ...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}), diff --git a/tests/unit/web-server/usage-aggregator-malformed-record.test.ts b/tests/unit/web-server/usage-aggregator-malformed-record.test.ts new file mode 100644 index 00000000..1c5e189a --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-malformed-record.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'bun:test'; +import { + mergeDailyData, + mergeHourlyData, + mergeMonthlyData, +} from '../../../src/web-server/usage/aggregator'; +import type { + DailyUsage, + HourlyUsage, + ModelBreakdown, + MonthlyUsage, +} from '../../../src/web-server/usage/types'; + +const breakdown: ModelBreakdown = { + modelName: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, +}; + +function validHour(): HourlyUsage { + return { + hour: '2026-03-02 10:00', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + requestCount: 1, + }; +} + +function validDay(): DailyUsage { + return { + date: '2026-03-02', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + }; +} + +function validMonth(): MonthlyUsage { + return { + month: '2026-03', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + }; +} + +/** + * Simulates a partially-written or legacy persisted usage record that is + * missing `modelBreakdowns` (and `requestCount`) despite the type declaring + * them, e.g. a Codex rollout file scanned mid-write. + */ +function stripBreakdowns(record: T): T { + const partial = { ...record } as Partial; + delete partial.modelBreakdowns; + if ('requestCount' in partial) { + delete (partial as { requestCount?: number }).requestCount; + } + return partial as T; +} + +describe('usage aggregator merge with missing modelBreakdowns', () => { + it('does not throw when a same-hour record is missing modelBreakdowns (merge-into-existing path)', () => { + const merged = mergeHourlyData([[validHour()], [stripBreakdowns(validHour())]]); + expect(merged).toHaveLength(1); + // requestCount stays numeric instead of throwing on `.length` of undefined. + expect(typeof merged[0]?.requestCount).toBe('number'); + // Tokens from both records still aggregate. + expect(merged[0]?.inputTokens).toBe(200); + }); + + it('does not throw when the only record is missing modelBreakdowns (new-bucket path)', () => { + const merged = mergeHourlyData([[stripBreakdowns(validHour())]]); + expect(merged).toHaveLength(1); + expect(merged[0]?.modelBreakdowns).toEqual([]); + expect(merged[0]?.requestCount).toBe(0); + }); + + it('daily merge tolerates a record missing modelBreakdowns', () => { + expect(() => mergeDailyData([[validDay()], [stripBreakdowns(validDay())]])).not.toThrow(); + }); + + it('monthly merge tolerates a record missing modelBreakdowns', () => { + expect(() => mergeMonthlyData([[validMonth()], [stripBreakdowns(validMonth())]])).not.toThrow(); + }); +});