mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:20:45 +00:00
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:
272
ui/src/ui/views/usage-metrics.test.ts
Normal file
272
ui/src/ui/views/usage-metrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user