diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 50a97e9aa43..98e62ee47d4 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -276,16 +276,16 @@ describe("session cost usage", () => { 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 + // 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?.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); + 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 () => { @@ -815,12 +815,12 @@ example ); const summary = await loadSessionCostSummary({ sessionFile }); - const hourly = summary?.hourlyMessageCounts; - expect(hourly).toBeDefined(); - expect(hourly?.length).toBe(4); + const quarterHourly = summary?.utcQuarterHourMessageCounts; + expect(quarterHourly).toBeDefined(); + expect(quarterHourly?.length).toBe(4); // Sort by quarterIndex for deterministic checks - const sorted = [...(hourly ?? [])].toSorted((a, b) => a.quarterIndex - b.quarterIndex); + 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 @@ -832,14 +832,14 @@ example expect(sorted[3]?.errors).toBe(1); // stopReason "error" }); - it("returns undefined hourlyMessageCounts when session has no messages", async () => { + 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?.hourlyMessageCounts).toBeUndefined(); + expect(summary?.utcQuarterHourMessageCounts).toBeUndefined(); }); it("preserves totals and cumulative values when downsampling timeseries", async () => { diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index b0724650746..97ef92c2988 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -34,11 +34,11 @@ import type { SessionDailyMessageCounts, SessionDailyModelUsage, SessionDailyUsage, - SessionHourlyMessageCounts, SessionLatencyStats, SessionLogEntry, SessionMessageCounts, SessionModelUsage, + SessionUtcQuarterHourMessageCounts, SessionToolUsage, SessionUsageTimePoint, SessionUsageTimeSeries, @@ -54,11 +54,11 @@ export type { SessionDailyMessageCounts, SessionDailyModelUsage, SessionDailyUsage, - SessionHourlyMessageCounts, SessionLatencyStats, SessionLogEntry, SessionMessageCounts, SessionModelUsage, + SessionUtcQuarterHourMessageCounts, SessionToolUsage, SessionUsageTimePoint, SessionUsageTimeSeries, @@ -171,8 +171,8 @@ 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. + * 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: { @@ -607,7 +607,7 @@ export async function loadSessionCostSummary(params: { const activityDatesSet = new Set(); const dailyMap = new Map(); const dailyMessageMap = new Map(); - const hourlyMessageMap = new Map(); + const utcQuarterHourMessageMap = new Map(); const dailyLatencyMap = new Map(); const dailyModelUsageMap = new Map(); const messageCounts: SessionMessageCounts = { @@ -714,7 +714,7 @@ export async function loadSessionCostSummary(params: { ); const utcDayKey = formatUtcDayKey(entry.timestamp); const quarterKey = `${utcDayKey}::${quarterIndex}`; - const hourly = hourlyMessageMap.get(quarterKey) ?? { + const utcQuarterHour = utcQuarterHourMessageMap.get(quarterKey) ?? { date: utcDayKey, quarterIndex, total: 0, @@ -724,8 +724,8 @@ export async function loadSessionCostSummary(params: { toolResults: 0, errors: 0, }; - accumulateMessageCounts(hourly, entry, errorStopReasons); - hourlyMessageMap.set(quarterKey, hourly); + accumulateMessageCounts(utcQuarterHour, entry, errorStopReasons); + utcQuarterHourMessageMap.set(quarterKey, utcQuarterHour); } if (!entry.usage) { @@ -811,8 +811,8 @@ export async function loadSessionCostSummary(params: { dailyMessageMap.values(), ).toSorted((a, b) => a.date.localeCompare(b.date)); - const hourlyMessageCounts: SessionHourlyMessageCounts[] = Array.from( - hourlyMessageMap.values(), + 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()) @@ -862,7 +862,9 @@ export async function loadSessionCostSummary(params: { activityDates: Array.from(activityDatesSet).toSorted(), dailyBreakdown, dailyMessageCounts, - hourlyMessageCounts: hourlyMessageCounts.length ? hourlyMessageCounts : undefined, + 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 fe97060c166..8f5f30b57ae 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -78,7 +78,7 @@ export type SessionDailyMessageCounts = { errors: number; }; -export type SessionHourlyMessageCounts = { +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; @@ -141,7 +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 + 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 index f26787c4871..8d78e42b056 100644 --- a/ui/src/ui/views/usage-metrics.test.ts +++ b/ui/src/ui/views/usage-metrics.test.ts @@ -3,7 +3,7 @@ import { buildPeakErrorHours } from "./usage-metrics.ts"; import type { UsageSessionEntry } from "./usageTypes.ts"; /** - * Helper: build a minimal UsageSessionEntry with hourlyMessageCounts + * Helper: build a minimal UsageSessionEntry with utcQuarterHourMessageCounts * using the new UTC quarter-hour bucket format. */ function makeSessionWithQuarterHourly( @@ -38,7 +38,7 @@ function makeSessionWithQuarterHourly( toolResults: 0, errors: buckets.reduce((sum, b) => sum + b.errors, 0), }, - hourlyMessageCounts: buckets.map((b) => ({ + utcQuarterHourMessageCounts: buckets.map((b) => ({ date: b.date, quarterIndex: b.quarterIndex, total: b.total, @@ -227,8 +227,8 @@ describe("buildPeakErrorHours", () => { expect(result[0].sub).toContain("30 msgs"); }); - it("falls back to proportional allocation when hourlyMessageCounts is absent", () => { - // Session without hourlyMessageCounts should use forEachSessionHourSlice + 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", @@ -255,7 +255,7 @@ describe("buildPeakErrorHours", () => { toolResults: 0, errors: 3, }, - // No hourlyMessageCounts → fallback path + // No utcQuarterHourMessageCounts -> fallback path }, } as unknown as UsageSessionEntry; diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index e856e5bcd1f..93594c2940c 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -88,17 +88,17 @@ function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" | // 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) { + if (usage.utcQuarterHourMessageCounts && usage.utcQuarterHourMessageCounts.length > 0) { + for (const quarterHour of usage.utcQuarterHourMessageCounts) { const hour = timeZone === "utc" - ? Math.floor(hourly.quarterIndex / 4) + ? Math.floor(quarterHour.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(); + 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] += hourly.errors; - hourMsgs[hour] += hourly.total; + hourErrors[hour] += quarterHour.errors; + hourMsgs[hour] += quarterHour.total; } continue; }