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.
This commit is contained in:
Tom Price committed 2026-07-12 21:23:31 -04:00
1 parent 507ce278b9
commit a7f3e4bc0e
2 files changed
+116 -7

No files matched your search

+10 -7
View File
@@ -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 } : {}),
@@ -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<T extends { modelBreakdowns: ModelBreakdown[] }>(record: T): T {
const partial = { ...record } as Partial<T>;
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();
});
});