mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +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:
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/update: tolerate stale memory-runtime import failures during best-effort CLI process teardown, so `openclaw update` replacing hashed runtime chunks before the finalizer runs no longer surfaces as exit-time `Cannot find module` noise. Thanks @vincentkoc.
|
||||
- CLI/channels logs: reuse the rolling log-file resolver so `openclaw channels logs` falls back to the active dated log across date boundaries without reading unrelated custom log files. Fixes #42875; carries forward #42904 and #43043. Thanks @ethanclaw and @wdskuki.
|
||||
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
|
||||
- Control UI: fix Peak Error Hours showing incorrect hourly rates when the browser's timezone observes DST, by storing hourly message counts with UTC date keys and using DST-aware `Date.getHours()` for local conversion. Also extract `accumulateMessageCounts` helper to reduce duplicated daily/hourly aggregation logic. (#49396) Thanks @konanok.
|
||||
- iMessage: normalize known leading attributedBody corruption markers on sent-message echo text keys so delayed reflected echoes with U+FFFD/U+FFFE/U+FFFF/FEFF prefixes are dropped without collapsing interior text. Fixes #59973; carries forward #59980 and #62191. Thanks @neeravmakwana and @maguilar631697.
|
||||
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
|
||||
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -274,6 +275,17 @@ describe("session cost usage", () => {
|
||||
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
|
||||
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.4");
|
||||
|
||||
// utcQuarterHourMessageCounts should use UTC quarter-hour buckets
|
||||
// start = 2026-02-01T10:00Z → quarterIndex = floor((10*60+0)/15) = 40
|
||||
// end = 2026-02-01T10:05Z → quarterIndex = floor((10*60+5)/15) = 40
|
||||
expect(summary?.utcQuarterHourMessageCounts).toBeDefined();
|
||||
expect(summary?.utcQuarterHourMessageCounts?.length).toBe(1);
|
||||
expect(summary?.utcQuarterHourMessageCounts?.[0]?.quarterIndex).toBe(40);
|
||||
expect(summary?.utcQuarterHourMessageCounts?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.utcQuarterHourMessageCounts?.[0]?.total).toBe(2);
|
||||
expect(summary?.utcQuarterHourMessageCounts?.[0]?.user).toBe(1);
|
||||
expect(summary?.utcQuarterHourMessageCounts?.[0]?.assistant).toBe(1);
|
||||
});
|
||||
|
||||
it("does not exclude sessions with mtime after endMs during discovery", async () => {
|
||||
@@ -753,6 +765,83 @@ example
|
||||
expect(logs?.[0]?.content).toBe("hello there");
|
||||
});
|
||||
|
||||
it("buckets hourly message counts into UTC quarter-hour slots", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-quarter-"));
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
|
||||
// Messages at different UTC quarter-hour boundaries:
|
||||
// 00:14 UTC → quarterIndex = floor((0*60+14)/15) = 0
|
||||
// 00:15 UTC → quarterIndex = floor((0*60+15)/15) = 1
|
||||
// 06:30 UTC → quarterIndex = floor((6*60+30)/15) = 26
|
||||
// 23:59 UTC → quarterIndex = floor((23*60+59)/15) = 95
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-03-15T00:14:00.000Z",
|
||||
message: { role: "user", content: "a" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-03-15T00:15:00.000Z",
|
||||
message: { role: "user", content: "b" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-03-15T06:30:00.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: { input: 5, output: 5, totalTokens: 10, cost: { total: 0.001 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-03-15T23:59:00.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
stopReason: "error",
|
||||
usage: { input: 3, output: 3, totalTokens: 6, cost: { total: 0.001 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const summary = await loadSessionCostSummary({ sessionFile });
|
||||
const quarterHourly = summary?.utcQuarterHourMessageCounts;
|
||||
expect(quarterHourly).toBeDefined();
|
||||
expect(quarterHourly?.length).toBe(4);
|
||||
|
||||
// Sort by quarterIndex for deterministic checks
|
||||
const sorted = [...(quarterHourly ?? [])].toSorted((a, b) => a.quarterIndex - b.quarterIndex);
|
||||
expect(sorted[0]?.quarterIndex).toBe(0); // 00:14
|
||||
expect(sorted[0]?.user).toBe(1);
|
||||
expect(sorted[1]?.quarterIndex).toBe(1); // 00:15
|
||||
expect(sorted[1]?.user).toBe(1);
|
||||
expect(sorted[2]?.quarterIndex).toBe(26); // 06:30
|
||||
expect(sorted[2]?.assistant).toBe(1);
|
||||
expect(sorted[3]?.quarterIndex).toBe(95); // 23:59
|
||||
expect(sorted[3]?.assistant).toBe(1);
|
||||
expect(sorted[3]?.errors).toBe(1); // stopReason "error"
|
||||
});
|
||||
|
||||
it("returns undefined utcQuarterHourMessageCounts when session has no messages", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-empty-hourly-"));
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
// Empty file — no entries at all
|
||||
await fs.writeFile(sessionFile, "", "utf-8");
|
||||
|
||||
const summary = await loadSessionCostSummary({ sessionFile });
|
||||
expect(summary?.utcQuarterHourMessageCounts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves totals and cumulative values when downsampling timeseries", async () => {
|
||||
const root = await makeSessionCostRoot("timeseries-downsample");
|
||||
const sessionsDir = path.join(root, "agents", "main", "sessions");
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
SessionLogEntry,
|
||||
SessionMessageCounts,
|
||||
SessionModelUsage,
|
||||
SessionUtcQuarterHourMessageCounts,
|
||||
SessionToolUsage,
|
||||
SessionUsageTimePoint,
|
||||
SessionUsageTimeSeries,
|
||||
@@ -57,6 +58,7 @@ export type {
|
||||
SessionLogEntry,
|
||||
SessionMessageCounts,
|
||||
SessionModelUsage,
|
||||
SessionUtcQuarterHourMessageCounts,
|
||||
SessionToolUsage,
|
||||
SessionUsageTimePoint,
|
||||
SessionUsageTimeSeries,
|
||||
@@ -165,6 +167,39 @@ const parseTranscriptEntry = (entry: Record<string, unknown>): ParsedTranscriptE
|
||||
const formatDayKey = (date: Date): string =>
|
||||
date.toLocaleDateString("en-CA", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
||||
|
||||
const formatUtcDayKey = (date: Date): string =>
|
||||
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
|
||||
/**
|
||||
* Accumulate message-level counts into a bucket (daily or UTC quarter-hour).
|
||||
* Avoids duplicating the same logic for both daily and quarter-hour message counts.
|
||||
*/
|
||||
const accumulateMessageCounts = (
|
||||
bucket: {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
},
|
||||
entry: ParsedTranscriptEntry,
|
||||
errorStopReasons: Set<string>,
|
||||
) => {
|
||||
bucket.total += entry.role === "user" || entry.role === "assistant" ? 1 : 0;
|
||||
if (entry.role === "user") {
|
||||
bucket.user += 1;
|
||||
} else if (entry.role === "assistant") {
|
||||
bucket.assistant += 1;
|
||||
}
|
||||
bucket.toolCalls += entry.toolNames.length;
|
||||
bucket.toolResults += entry.toolResultCounts.total;
|
||||
bucket.errors += entry.toolResultCounts.errors;
|
||||
if (entry.stopReason && errorStopReasons.has(entry.stopReason)) {
|
||||
bucket.errors += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const computeLatencyStats = (values: number[]): SessionLatencyStats | undefined => {
|
||||
if (!values.length) {
|
||||
return undefined;
|
||||
@@ -572,6 +607,7 @@ export async function loadSessionCostSummary(params: {
|
||||
const activityDatesSet = new Set<string>();
|
||||
const dailyMap = new Map<string, { tokens: number; cost: number }>();
|
||||
const dailyMessageMap = new Map<string, SessionDailyMessageCounts>();
|
||||
const utcQuarterHourMessageMap = new Map<string, SessionUtcQuarterHourMessageCounts>();
|
||||
const dailyLatencyMap = new Map<string, number[]>();
|
||||
const dailyModelUsageMap = new Map<string, SessionDailyModelUsage>();
|
||||
const messageCounts: SessionMessageCounts = {
|
||||
@@ -669,19 +705,27 @@ export async function loadSessionCostSummary(params: {
|
||||
toolResults: 0,
|
||||
errors: 0,
|
||||
};
|
||||
daily.total += entry.role === "user" || entry.role === "assistant" ? 1 : 0;
|
||||
if (entry.role === "user") {
|
||||
daily.user += 1;
|
||||
} else if (entry.role === "assistant") {
|
||||
daily.assistant += 1;
|
||||
}
|
||||
daily.toolCalls += entry.toolNames.length;
|
||||
daily.toolResults += entry.toolResultCounts.total;
|
||||
daily.errors += entry.toolResultCounts.errors;
|
||||
if (entry.stopReason && errorStopReasons.has(entry.stopReason)) {
|
||||
daily.errors += 1;
|
||||
}
|
||||
accumulateMessageCounts(daily, entry, errorStopReasons);
|
||||
dailyMessageMap.set(dayKey, daily);
|
||||
|
||||
// Per-quarter-hour message counts for precise hourly stats (UTC-based)
|
||||
const quarterIndex = Math.floor(
|
||||
(entry.timestamp.getUTCHours() * 60 + entry.timestamp.getUTCMinutes()) / 15,
|
||||
);
|
||||
const utcDayKey = formatUtcDayKey(entry.timestamp);
|
||||
const quarterKey = `${utcDayKey}::${quarterIndex}`;
|
||||
const utcQuarterHour = utcQuarterHourMessageMap.get(quarterKey) ?? {
|
||||
date: utcDayKey,
|
||||
quarterIndex,
|
||||
total: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
errors: 0,
|
||||
};
|
||||
accumulateMessageCounts(utcQuarterHour, entry, errorStopReasons);
|
||||
utcQuarterHourMessageMap.set(quarterKey, utcQuarterHour);
|
||||
}
|
||||
|
||||
if (!entry.usage) {
|
||||
@@ -767,6 +811,10 @@ export async function loadSessionCostSummary(params: {
|
||||
dailyMessageMap.values(),
|
||||
).toSorted((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const utcQuarterHourMessageCounts: SessionUtcQuarterHourMessageCounts[] = Array.from(
|
||||
utcQuarterHourMessageMap.values(),
|
||||
).toSorted((a, b) => a.date.localeCompare(b.date) || a.quarterIndex - b.quarterIndex);
|
||||
|
||||
const dailyLatency: SessionDailyLatency[] = Array.from(dailyLatencyMap.entries())
|
||||
.map(([date, values]) => {
|
||||
const stats = computeLatencyStats(values);
|
||||
@@ -814,6 +862,9 @@ export async function loadSessionCostSummary(params: {
|
||||
activityDates: Array.from(activityDatesSet).toSorted(),
|
||||
dailyBreakdown,
|
||||
dailyMessageCounts,
|
||||
utcQuarterHourMessageCounts: utcQuarterHourMessageCounts.length
|
||||
? utcQuarterHourMessageCounts
|
||||
: undefined,
|
||||
dailyLatency: dailyLatency.length ? dailyLatency : undefined,
|
||||
dailyModelUsage: dailyModelUsage.length ? dailyModelUsage : undefined,
|
||||
messageCounts,
|
||||
|
||||
@@ -78,6 +78,17 @@ export type SessionDailyMessageCounts = {
|
||||
errors: number;
|
||||
};
|
||||
|
||||
export type SessionUtcQuarterHourMessageCounts = {
|
||||
date: string; // YYYY-MM-DD (UTC)
|
||||
quarterIndex: number; // 0-95, UTC quarter-hour bucket (index = floor((utcH * 60 + utcM) / 15))
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
export type SessionLatencyStats = {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
@@ -130,6 +141,7 @@ export type SessionCostSummary = CostUsageTotals & {
|
||||
activityDates?: string[]; // YYYY-MM-DD dates when session had activity
|
||||
dailyBreakdown?: SessionDailyUsage[]; // Per-day token/cost breakdown
|
||||
dailyMessageCounts?: SessionDailyMessageCounts[];
|
||||
utcQuarterHourMessageCounts?: SessionUtcQuarterHourMessageCounts[]; // UTC quarter-hour buckets for precise hourly stats
|
||||
dailyLatency?: SessionDailyLatency[];
|
||||
dailyModelUsage?: SessionDailyModelUsage[];
|
||||
messageCounts?: SessionMessageCounts;
|
||||
|
||||
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