fix(ui): use precise hourly message counts for Peak Error Hours (#49396)

Merged via squash.

Prepared head SHA: fbbf43b84a
Co-authored-by: konanok <30515586+konanok@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
konanok
2026-04-29 15:22:42 +08:00
committed by GitHub
parent a0fd105e5e
commit bd5afadc5c
6 changed files with 462 additions and 16 deletions

View File

@@ -0,0 +1,272 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { buildPeakErrorHours } from "./usage-metrics.ts";
import type { UsageSessionEntry } from "./usageTypes.ts";
/**
* Helper: build a minimal UsageSessionEntry with utcQuarterHourMessageCounts
* using the new UTC quarter-hour bucket format.
*/
function makeSessionWithQuarterHourly(
buckets: Array<{
date: string;
quarterIndex: number;
total: number;
errors: number;
}>,
): UsageSessionEntry {
return {
key: "test-session",
usage: {
totalTokens: 100,
totalCost: 0.01,
input: 50,
output: 50,
cacheRead: 0,
cacheWrite: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
firstActivity: Date.now() - 3600_000,
lastActivity: Date.now(),
messageCounts: {
total: buckets.reduce((sum, b) => sum + b.total, 0),
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: buckets.reduce((sum, b) => sum + b.errors, 0),
},
utcQuarterHourMessageCounts: buckets.map((b) => ({
date: b.date,
quarterIndex: b.quarterIndex,
total: b.total,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: b.errors,
})),
},
} as unknown as UsageSessionEntry;
}
describe("buildPeakErrorHours", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("maps UTC quarter-hour buckets to correct hours in UTC mode", () => {
// quarterIndex 0 → 00:00-00:14 UTC → hour 0
// quarterIndex 4 → 01:00-01:14 UTC → hour 1
// quarterIndex 36 → 09:00-09:14 UTC → hour 9
// quarterIndex 95 → 23:45-23:59 UTC → hour 23
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 0, total: 10, errors: 5 },
{ date: "2026-03-15", quarterIndex: 4, total: 20, errors: 2 },
{ date: "2026-03-15", quarterIndex: 36, total: 15, errors: 3 },
{ date: "2026-03-15", quarterIndex: 95, total: 8, errors: 4 },
]);
const result = buildPeakErrorHours([session], "utc");
// All hours with errors should appear, sorted by error rate desc
expect(result.length).toBeGreaterThan(0);
expect(result.length).toBeLessThanOrEqual(5);
// Verify that hour mappings are correct by checking all returned entries
const _hourSet = new Set(result.map((r) => r.label));
// The hours present should correspond to UTC hours 0, 1, 9, 23
// formatHourLabel uses Date.setHours so labels depend on locale,
// but we can verify error rates and sub info
const highestRate = result[0];
expect(highestRate).toBeDefined();
// hour 0: 5/10 = 50%, hour 23: 4/8 = 50%, hour 9: 3/15 = 20%, hour 1: 2/20 = 10%
expect(highestRate.value).toMatch(/50\.00%/);
});
it("aggregates multiple quarter-hour buckets into the same hour in UTC mode", () => {
// quarterIndex 0 (00:00) and quarterIndex 3 (00:45) both map to hour 0
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 0, total: 10, errors: 2 },
{ date: "2026-03-15", quarterIndex: 3, total: 5, errors: 3 },
]);
const result = buildPeakErrorHours([session], "utc");
expect(result.length).toBe(1);
// Aggregated: 5 errors / 15 total = 33.33%
expect(result[0].value).toBe("33.33%");
expect(result[0].sub).toContain("5 errors");
expect(result[0].sub).toContain("15 msgs");
});
it("shifts UTC quarter-hour buckets to local timezone in local mode", () => {
// Simulate UTC+5: UTC hour 0 → local hour 5, UTC hour 10 → local hour 15
vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) {
return (this.getUTCHours() + 5) % 24;
});
// quarterIndex 0 → UTC 00:00 → local 05:00
// quarterIndex 40 → UTC 10:00 → local 15:00
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 0, total: 10, errors: 3 },
{ date: "2026-03-15", quarterIndex: 40, total: 20, errors: 4 },
]);
const result = buildPeakErrorHours([session], "local");
expect(result.length).toBe(2);
// Verify the sub info matches aggregated values
const subs = result.map((r) => r.sub);
expect(subs).toContain("3 errors · 10 msgs"); // local hour 5
expect(subs).toContain("4 errors · 20 msgs"); // local hour 15
});
it("wraps correctly for negative local timezone (UTC-8)", () => {
// Simulate UTC-8: UTC hour 0 → local hour 16 (previous day)
vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) {
return (this.getUTCHours() - 8 + 24) % 24;
});
// quarterIndex 0 → UTC 00:00 → local 16:00
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 0, total: 10, errors: 5 },
]);
const result = buildPeakErrorHours([session], "local");
expect(result.length).toBe(1);
expect(result[0].value).toBe("50.00%");
expect(result[0].sub).toContain("5 errors");
expect(result[0].sub).toContain("10 msgs");
});
it("wraps correctly for positive local timezone near midnight (UTC+8, late quarter)", () => {
// Simulate UTC+8: UTC hour 17 → local hour 1 (next day)
vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) {
return (this.getUTCHours() + 8) % 24;
});
// quarterIndex 68 → UTC 17:00 → local 01:00 (next day)
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 68, total: 12, errors: 6 },
]);
const result = buildPeakErrorHours([session], "local");
expect(result.length).toBe(1);
expect(result[0].value).toBe("50.00%");
expect(result[0].sub).toContain("6 errors");
expect(result[0].sub).toContain("12 msgs");
});
it("returns empty array when no sessions have errors", () => {
const session = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 10, total: 50, errors: 0 },
]);
const result = buildPeakErrorHours([session], "utc");
expect(result).toEqual([]);
});
it("returns empty array when sessions have no message counts", () => {
const session: UsageSessionEntry = {
key: "empty",
usage: {
totalTokens: 0,
totalCost: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
messageCounts: { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 },
},
} as unknown as UsageSessionEntry;
const result = buildPeakErrorHours([session], "utc");
expect(result).toEqual([]);
});
it("limits results to at most 5 entries sorted by error rate", () => {
// Create 8 different hours with errors
const buckets = Array.from({ length: 8 }, (_, i) => ({
date: "2026-03-15",
quarterIndex: i * 8, // hours 0,2,4,6,8,10,12,14
total: 100,
errors: (i + 1) * 2, // increasing error counts
}));
const session = makeSessionWithQuarterHourly(buckets);
const result = buildPeakErrorHours([session], "utc");
expect(result.length).toBe(5);
// Should be sorted by rate descending — highest rate first
const rates = result.map((r) => Number.parseFloat(r.value));
for (let i = 1; i < rates.length; i++) {
expect(rates[i - 1]).toBeGreaterThanOrEqual(rates[i]);
}
});
it("aggregates across multiple sessions", () => {
const session1 = makeSessionWithQuarterHourly([
{ date: "2026-03-15", quarterIndex: 20, total: 10, errors: 3 },
]);
const session2 = makeSessionWithQuarterHourly([
{ date: "2026-03-16", quarterIndex: 20, total: 20, errors: 7 },
]);
const result = buildPeakErrorHours([session1, session2], "utc");
expect(result.length).toBe(1);
// quarterIndex 20 → hour 5: aggregated 10 errors / 30 msgs = 33.33%
expect(result[0].value).toBe("33.33%");
expect(result[0].sub).toContain("10 errors");
expect(result[0].sub).toContain("30 msgs");
});
it("falls back to proportional allocation when utcQuarterHourMessageCounts is absent", () => {
// Session without utcQuarterHourMessageCounts should use forEachSessionHourSlice
const now = Date.now();
const session: UsageSessionEntry = {
key: "fallback-session",
updatedAt: now,
usage: {
totalTokens: 100,
totalCost: 0.01,
input: 50,
output: 50,
cacheRead: 0,
cacheWrite: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
firstActivity: now - 3600_000,
lastActivity: now,
messageCounts: {
total: 10,
user: 5,
assistant: 5,
toolCalls: 0,
toolResults: 0,
errors: 3,
},
// No utcQuarterHourMessageCounts -> fallback path
},
} as unknown as UsageSessionEntry;
const result = buildPeakErrorHours([session], "utc");
// Should still produce results via the proportional allocation fallback
expect(result.length).toBeGreaterThan(0);
// All errors (3) should be distributed proportionally
const totalErrors = result.reduce((sum, r) => {
const match = r.sub.match(/(\d+) errors/);
return sum + (match ? Number.parseInt(match[1], 10) : 0);
}, 0);
expect(totalErrors).toBe(3);
});
});

View File

@@ -79,13 +79,34 @@ function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" |
const hourMsgs = Array.from({ length: 24 }, () => 0);
for (const session of sessions) {
const messageCounts = session.usage?.messageCounts;
if (!messageCounts || messageCounts.total === 0) {
const usage = session.usage;
if (!usage?.messageCounts || usage.messageCounts.total === 0) {
continue;
}
// Prefer precise quarter-hour message counts when available.
// Data is stored as UTC quarter-hour buckets (quarterIndex 0-95) with UTC date keys.
// For local view, construct a Date from the UTC components and use getHours()
// so the browser's DST-aware timezone logic handles offset automatically.
if (usage.utcQuarterHourMessageCounts && usage.utcQuarterHourMessageCounts.length > 0) {
for (const quarterHour of usage.utcQuarterHourMessageCounts) {
const hour =
timeZone === "utc"
? Math.floor(quarterHour.quarterIndex / 4)
: (() => {
const [y, m, d] = quarterHour.date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d, 0, quarterHour.quarterIndex * 15)).getHours();
})();
hourErrors[hour] += quarterHour.errors;
hourMsgs[hour] += quarterHour.total;
}
continue;
}
// Fallback: time-based proportional allocation (legacy algorithm)
forEachSessionHourSlice(session, timeZone, ({ hour, share }) => {
hourErrors[hour] += messageCounts.errors * share;
hourMsgs[hour] += messageCounts.total * share;
hourErrors[hour] += usage.messageCounts!.errors * share;
hourMsgs[hour] += usage.messageCounts!.total * share;
});
}