From 0dcb7aacfcf2d87fe4a2321c82e0c84821dcd854 Mon Sep 17 00:00:00 2001 From: konanok Date: Wed, 18 Mar 2026 11:03:44 +0800 Subject: [PATCH] fix: use precise hourly message counts for Peak Error Hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Peak Error Hours widget displayed identical error rates for all hours because the legacy algorithm distributed errors proportionally across session time spans. This produced misleading data (e.g., every hour showing 9.89%) instead of highlighting actual error-heavy periods. Changes: - Add SessionHourlyMessageCounts type with quarterIndex field (0-95) representing UTC-based 15-minute buckets for timezone-accurate hour conversion — covers all global UTC offsets (±15/30/45 min) - Collect per-quarter-hour message/error counts in loadSessionCostSummary using getUTCHours()/getUTCMinutes() with UTC day keys (formatUtcDayKey) - Prefer precise quarterly data in buildPeakErrorHours for both local and UTC views, falling back to proportional allocation for sessions without quarterly data - Fix DST bug: replace single getTimezoneOffset() with new Date(Date.UTC(...)).getHours() for DST-aware local hour mapping - Extract accumulateMessageCounts helper to deduplicate daily/hourly message count aggregation logic - Guard hourlyMessageCounts with .length check before including in summary (consistent with dailyLatency/dailyModelUsage pattern) --- CHANGELOG.md | 1 + src/infra/session-cost-usage.test.ts | 89 +++++++++ src/infra/session-cost-usage.ts | 73 +++++-- 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, 460 insertions(+), 16 deletions(-) create mode 100644 ui/src/ui/views/usage-metrics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fddc2b4bf25..a0d77d09c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - CLI/channel-setup: auto-skip the redundant "Install \?" confirmation when only one install source (npm or local) exists, show `download from ` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp. - BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris. - Agents/approvals: fail restart-interrupted sessions whose transcript tail is still `approval-pending` instead of replaying stale exec approval IDs into the new Gateway process after restart. Fixes #65486. Thanks @mjmai20682068-create. diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 8a1b0eb89f9..50a97e9aa43 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"); + + // hourlyMessageCounts 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?.hourlyMessageCounts).toBeDefined(); + expect(summary?.hourlyMessageCounts?.length).toBe(1); + expect(summary?.hourlyMessageCounts?.[0]?.quarterIndex).toBe(40); + expect(summary?.hourlyMessageCounts?.[0]?.date).toBe("2026-02-01"); + expect(summary?.hourlyMessageCounts?.[0]?.total).toBe(2); + expect(summary?.hourlyMessageCounts?.[0]?.user).toBe(1); + expect(summary?.hourlyMessageCounts?.[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 hourly = summary?.hourlyMessageCounts; + expect(hourly).toBeDefined(); + expect(hourly?.length).toBe(4); + + // Sort by quarterIndex for deterministic checks + const sorted = [...(hourly ?? [])].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 hourlyMessageCounts 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?.hourlyMessageCounts).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..b0724650746 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -34,6 +34,7 @@ import type { SessionDailyMessageCounts, SessionDailyModelUsage, SessionDailyUsage, + SessionHourlyMessageCounts, SessionLatencyStats, SessionLogEntry, SessionMessageCounts, @@ -53,6 +54,7 @@ export type { SessionDailyMessageCounts, SessionDailyModelUsage, SessionDailyUsage, + SessionHourlyMessageCounts, SessionLatencyStats, SessionLogEntry, SessionMessageCounts, @@ -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 hourly). + * Avoids duplicating the same logic for both SessionDailyMessageCounts and SessionHourlyMessageCounts. + */ +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 hourlyMessageMap = 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 hourly = hourlyMessageMap.get(quarterKey) ?? { + date: utcDayKey, + quarterIndex, + total: 0, + user: 0, + assistant: 0, + toolCalls: 0, + toolResults: 0, + errors: 0, + }; + accumulateMessageCounts(hourly, entry, errorStopReasons); + hourlyMessageMap.set(quarterKey, hourly); } if (!entry.usage) { @@ -767,6 +811,10 @@ export async function loadSessionCostSummary(params: { dailyMessageMap.values(), ).toSorted((a, b) => a.date.localeCompare(b.date)); + const hourlyMessageCounts: SessionHourlyMessageCounts[] = Array.from( + hourlyMessageMap.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,7 @@ export async function loadSessionCostSummary(params: { activityDates: Array.from(activityDatesSet).toSorted(), dailyBreakdown, dailyMessageCounts, + hourlyMessageCounts: hourlyMessageCounts.length ? hourlyMessageCounts : 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..fe97060c166 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 SessionHourlyMessageCounts = { + 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[]; + hourlyMessageCounts?: SessionHourlyMessageCounts[]; // Per-hour message counts 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..f26787c4871 --- /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 hourlyMessageCounts + * 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), + }, + hourlyMessageCounts: 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 hourlyMessageCounts is absent", () => { + // Session without hourlyMessageCounts 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 hourlyMessageCounts → 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..e856e5bcd1f 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.hourlyMessageCounts && usage.hourlyMessageCounts.length > 0) { + for (const hourly of usage.hourlyMessageCounts) { + const hour = + timeZone === "utc" + ? Math.floor(hourly.quarterIndex / 4) + : (() => { + const [y, m, d] = hourly.date.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d, 0, hourly.quarterIndex * 15)).getHours(); + })(); + hourErrors[hour] += hourly.errors; + hourMsgs[hour] += hourly.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; }); }