From bd5afadc5c7a0575093d4e15fe518fe22d9792cb Mon Sep 17 00:00:00 2001 From: konanok Date: Wed, 29 Apr 2026 15:22:42 +0800 Subject: [PATCH] fix(ui): use precise hourly message counts for Peak Error Hours (#49396) Merged via squash. Prepared head SHA: fbbf43b84a259a9a0198fe969ed656e461312f31 Co-authored-by: konanok <30515586+konanok@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 1 + src/infra/session-cost-usage.test.ts | 89 +++++++++ src/infra/session-cost-usage.ts | 75 +++++-- src/infra/session-cost-usage.types.ts | 12 ++ ui/src/ui/views/usage-metrics.test.ts | 272 ++++++++++++++++++++++++++ ui/src/ui/views/usage-metrics.ts | 29 ++- 6 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 ui/src/ui/views/usage-metrics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b5551cf98..b80fe19ff7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 8a1b0eb89f9..98e62ee47d4 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -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"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index f69f6cdb481..97ef92c2988 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -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): 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, +) => { + 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(); const dailyMap = new Map(); const dailyMessageMap = new Map(); + const utcQuarterHourMessageMap = new Map(); const dailyLatencyMap = new Map(); const dailyModelUsageMap = new Map(); 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, diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 70de453bcd9..8f5f30b57ae 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -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; diff --git a/ui/src/ui/views/usage-metrics.test.ts b/ui/src/ui/views/usage-metrics.test.ts new file mode 100644 index 00000000000..8d78e42b056 --- /dev/null +++ b/ui/src/ui/views/usage-metrics.test.ts @@ -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); + }); +}); diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index 27c0ebd7c31..93594c2940c 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -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; }); }