From 0eb8f34000b85399643b7f3f2456454c6b8a15a4 Mon Sep 17 00:00:00 2001 From: konanok Date: Thu, 30 Apr 2026 23:32:34 +0800 Subject: [PATCH] refactor(usage): add precise token buckets for Usage Mosaic (#74337) Merged via squash. Prepared head SHA: 15185354c46914d2fdb17d818230c76001891e7f 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 | 141 +++++++++++++++++++++++ src/infra/session-cost-usage.ts | 99 +++++++++++----- src/infra/session-cost-usage.types.ts | 16 +++ ui/src/ui/views/usage-metrics.test.ts | 159 +++++++++++++++++++++++++- ui/src/ui/views/usage-metrics.ts | 139 ++++++++++++++++++++-- ui/src/ui/views/usage.ts | 33 +----- 7 files changed, 518 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb9690e206..5392de0e0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box. +- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok. ### Fixes diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 98e62ee47d4..4645da3bdf4 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -832,6 +832,146 @@ example expect(sorted[3]?.errors).toBe(1); // stopReason "error" }); + it("captures UTC quarter-hour token usage buckets without proportional allocation", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-token-hourly-")); + const sessionFile = path.join(root, "session.jsonl"); + const entries = [ + { + type: "message", + timestamp: "2026-03-15T06:30:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 5, + output: 7, + cache_read: 3, + cache_creation_input_tokens: 2, + totalTokens: 25, + cost: { total: 0.025 }, + }, + }, + }, + { + type: "message", + timestamp: "2026-03-15T06:35:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 1, + output: 2, + cache_read: 3, + cache_creation_input_tokens: 4, + cost: { total: 0.01 }, + }, + }, + }, + { + type: "message", + timestamp: "2026-03-15T23:59:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { input: 2, output: 3, totalTokens: 9, cost: { total: 0.009 } }, + }, + }, + ]; + + await fs.writeFile( + sessionFile, + entries.map((entry) => JSON.stringify(entry)).join("\n"), + "utf-8", + ); + + const summary = await loadSessionCostSummary({ sessionFile }); + const tokenBuckets = summary?.utcQuarterHourTokenUsage; + expect(tokenBuckets).toBeDefined(); + expect(tokenBuckets).toHaveLength(2); + + const sorted = [...(tokenBuckets ?? [])].toSorted((a, b) => a.quarterIndex - b.quarterIndex); + expect(sorted[0]).toMatchObject({ + date: "2026-03-15", + quarterIndex: 26, + input: 6, + output: 9, + cacheRead: 6, + cacheWrite: 6, + totalTokens: 35, + }); + expect(sorted[0]?.totalCost).toBeCloseTo(0.035, 6); + expect(sorted[1]).toMatchObject({ + date: "2026-03-15", + quarterIndex: 95, + input: 2, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 9, + }); + expect(sorted[1]?.totalCost).toBeCloseTo(0.009, 6); + }); + + it("splits UTC quarter-hour token usage buckets across UTC day boundaries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-token-midnight-")); + const sessionFile = path.join(root, "session.jsonl"); + const entries = [ + { + type: "message", + timestamp: "2026-03-15T23:59:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { input: 2, output: 3, totalTokens: 9, cost: { total: 0.009 } }, + }, + }, + { + type: "message", + timestamp: "2026-03-16T00:00:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { input: 4, output: 5, totalTokens: 11, cost: { total: 0.011 } }, + }, + }, + ]; + + await fs.writeFile( + sessionFile, + entries.map((entry) => JSON.stringify(entry)).join("\n"), + "utf-8", + ); + + const summary = await loadSessionCostSummary({ sessionFile }); + expect(summary?.utcQuarterHourTokenUsage).toEqual([ + { + date: "2026-03-15", + quarterIndex: 95, + input: 2, + output: 3, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 9, + totalCost: 0.009, + }, + { + date: "2026-03-16", + quarterIndex: 0, + input: 4, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 11, + totalCost: 0.011, + }, + ]); + }); + 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"); @@ -840,6 +980,7 @@ example const summary = await loadSessionCostSummary({ sessionFile }); expect(summary?.utcQuarterHourMessageCounts).toBeUndefined(); + expect(summary?.utcQuarterHourTokenUsage).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 97ef92c2988..e30daac09ce 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -39,6 +39,7 @@ import type { SessionMessageCounts, SessionModelUsage, SessionUtcQuarterHourMessageCounts, + SessionUtcQuarterHourTokenUsage, SessionToolUsage, SessionUsageTimePoint, SessionUsageTimeSeries, @@ -59,6 +60,7 @@ export type { SessionMessageCounts, SessionModelUsage, SessionUtcQuarterHourMessageCounts, + SessionUtcQuarterHourTokenUsage, SessionToolUsage, SessionUsageTimePoint, SessionUsageTimeSeries, @@ -170,6 +172,14 @@ const formatDayKey = (date: Date): string => const formatUtcDayKey = (date: Date): string => `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; +const getUtcQuarterHourBucketKey = ( + date: Date, +): { date: string; quarterIndex: number; key: string } => { + const quarterIndex = Math.floor((date.getUTCHours() * 60 + date.getUTCMinutes()) / 15); + const utcDayKey = formatUtcDayKey(date); + return { date: utcDayKey, quarterIndex, key: `${utcDayKey}::${quarterIndex}` }; +}; + /** * 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. @@ -217,15 +227,29 @@ const computeLatencyStats = (values: number[]): SessionLatencyStats | undefined }; }; +const computeUsageTokenTotals = (usage: NormalizedUsage) => { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const componentTotal = input + output + cacheRead + cacheWrite; + return { + input, + output, + cacheRead, + cacheWrite, + componentTotal, + totalTokens: usage.total ?? componentTotal, + }; +}; + const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => { - totals.input += usage.input ?? 0; - totals.output += usage.output ?? 0; - totals.cacheRead += usage.cacheRead ?? 0; - totals.cacheWrite += usage.cacheWrite ?? 0; - const totalTokens = - usage.total ?? - (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - totals.totalTokens += totalTokens; + const usageTotals = computeUsageTokenTotals(usage); + totals.input += usageTotals.input; + totals.output += usageTotals.output; + totals.cacheRead += usageTotals.cacheRead; + totals.cacheWrite += usageTotals.cacheWrite; + totals.totalTokens += usageTotals.totalTokens; }; const applyCostBreakdown = (totals: CostUsageTotals, costBreakdown: CostBreakdown | undefined) => { @@ -608,6 +632,7 @@ export async function loadSessionCostSummary(params: { const dailyMap = new Map(); const dailyMessageMap = new Map(); const utcQuarterHourMessageMap = new Map(); + const utcQuarterHourTokenMap = new Map(); const dailyLatencyMap = new Map(); const dailyModelUsageMap = new Map(); const messageCounts: SessionMessageCounts = { @@ -709,14 +734,10 @@ export async function loadSessionCostSummary(params: { 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, + const quarterBucket = getUtcQuarterHourBucketKey(entry.timestamp); + const utcQuarterHour = utcQuarterHourMessageMap.get(quarterBucket.key) ?? { + date: quarterBucket.date, + quarterIndex: quarterBucket.quarterIndex, total: 0, user: 0, assistant: 0, @@ -725,7 +746,7 @@ export async function loadSessionCostSummary(params: { errors: 0, }; accumulateMessageCounts(utcQuarterHour, entry, errorStopReasons); - utcQuarterHourMessageMap.set(quarterKey, utcQuarterHour); + utcQuarterHourMessageMap.set(quarterBucket.key, utcQuarterHour); } if (!entry.usage) { @@ -741,11 +762,11 @@ export async function loadSessionCostSummary(params: { if (entry.timestamp) { const dayKey = formatDayKey(entry.timestamp); - const entryTokens = - (entry.usage.input ?? 0) + - (entry.usage.output ?? 0) + - (entry.usage.cacheRead ?? 0) + - (entry.usage.cacheWrite ?? 0); + const entryTokenTotals = computeUsageTokenTotals(entry.usage); + // Preserve the legacy dailyBreakdown token basis until daily metrics are + // refactored separately. The precise quarter-hour bucket below uses + // entryTokenTotals.totalTokens so Usage Mosaic matches session totals. + const entryTokens = entryTokenTotals.componentTotal; const entryCost = entry.costBreakdown?.total ?? (entry.costBreakdown @@ -755,6 +776,25 @@ export async function loadSessionCostSummary(params: { (entry.costBreakdown.cacheWrite ?? 0) : (entry.costTotal ?? 0)); + const quarterBucket = getUtcQuarterHourBucketKey(entry.timestamp); + const utcQuarterHourToken = utcQuarterHourTokenMap.get(quarterBucket.key) ?? { + date: quarterBucket.date, + quarterIndex: quarterBucket.quarterIndex, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + }; + utcQuarterHourToken.input += entryTokenTotals.input; + utcQuarterHourToken.output += entryTokenTotals.output; + utcQuarterHourToken.cacheRead += entryTokenTotals.cacheRead; + utcQuarterHourToken.cacheWrite += entryTokenTotals.cacheWrite; + utcQuarterHourToken.totalTokens += entryTokenTotals.totalTokens; + utcQuarterHourToken.totalCost += entryCost; + utcQuarterHourTokenMap.set(quarterBucket.key, utcQuarterHourToken); + const existing = dailyMap.get(dayKey) ?? { tokens: 0, cost: 0 }; dailyMap.set(dayKey, { tokens: existing.tokens + entryTokens, @@ -815,6 +855,10 @@ export async function loadSessionCostSummary(params: { utcQuarterHourMessageMap.values(), ).toSorted((a, b) => a.date.localeCompare(b.date) || a.quarterIndex - b.quarterIndex); + const utcQuarterHourTokenUsage: SessionUtcQuarterHourTokenUsage[] = Array.from( + utcQuarterHourTokenMap.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); @@ -865,6 +909,9 @@ export async function loadSessionCostSummary(params: { utcQuarterHourMessageCounts: utcQuarterHourMessageCounts.length ? utcQuarterHourMessageCounts : undefined, + utcQuarterHourTokenUsage: utcQuarterHourTokenUsage.length + ? utcQuarterHourTokenUsage + : undefined, dailyLatency: dailyLatency.length ? dailyLatency : undefined, dailyModelUsage: dailyModelUsage.length ? dailyModelUsage : undefined, messageCounts, @@ -901,11 +948,9 @@ export async function loadSessionUsageTimeSeries(params: { return; } - const input = entry.usage.input ?? 0; - const output = entry.usage.output ?? 0; - const cacheRead = entry.usage.cacheRead ?? 0; - const cacheWrite = entry.usage.cacheWrite ?? 0; - const totalTokens = entry.usage.total ?? input + output + cacheRead + cacheWrite; + const { input, output, cacheRead, cacheWrite, totalTokens } = computeUsageTokenTotals( + entry.usage, + ); const cost = entry.costTotal ?? 0; cumulativeTokens += totalTokens; diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 8f5f30b57ae..f7da6cc73ca 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -89,6 +89,21 @@ export type SessionUtcQuarterHourMessageCounts = { errors: number; }; +export type SessionUtcQuarterHourTokenUsage = { + date: string; // YYYY-MM-DD (UTC) + quarterIndex: number; // 0-95, UTC quarter-hour bucket (index = floor((utcH * 60 + utcM) / 15)) + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + // Uses the same token total basis as CostUsageTotals: usage.total when present, + // otherwise input + output + cacheRead + cacheWrite. This intentionally differs + // from legacy dailyBreakdown.tokens, which preserves its existing component-sum + // behavior until daily usage buckets are refactored separately. + totalTokens: number; + totalCost: number; +}; + export type SessionLatencyStats = { count: number; avgMs: number; @@ -142,6 +157,7 @@ export type SessionCostSummary = CostUsageTotals & { dailyBreakdown?: SessionDailyUsage[]; // Per-day token/cost breakdown dailyMessageCounts?: SessionDailyMessageCounts[]; utcQuarterHourMessageCounts?: SessionUtcQuarterHourMessageCounts[]; // UTC quarter-hour buckets for precise hourly stats + utcQuarterHourTokenUsage?: SessionUtcQuarterHourTokenUsage[]; // UTC quarter-hour buckets for precise token mosaic 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 8d78e42b056..adb8200354e 100644 --- a/ui/src/ui/views/usage-metrics.test.ts +++ b/ui/src/ui/views/usage-metrics.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi, afterEach } from "vitest"; -import { buildPeakErrorHours } from "./usage-metrics.ts"; +import { + buildPeakErrorHours, + buildUsageMosaicStats, + getHourAndWeekdayForUtcQuarterBucket, + sessionTouchesSelectedHours, +} from "./usage-metrics.ts"; import type { UsageSessionEntry } from "./usageTypes.ts"; /** @@ -75,11 +80,9 @@ describe("buildPeakErrorHours", () => { 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 + // 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 + // 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% @@ -270,3 +273,149 @@ describe("buildPeakErrorHours", () => { expect(totalErrors).toBe(3); }); }); + +describe("usage mosaic token buckets", () => { + const makeSessionWithTokenBuckets = ( + buckets: Array<{ + date: string; + quarterIndex: number; + totalTokens: number; + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }>, + ): UsageSessionEntry => + ({ + key: "token-bucket-session", + usage: { + totalTokens: buckets.reduce((sum, bucket) => sum + bucket.totalTokens, 0), + totalCost: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + firstActivity: Date.parse("2026-02-01T10:00:00.000Z"), + lastActivity: Date.parse("2026-02-01T12:00:00.000Z"), + utcQuarterHourTokenUsage: buckets.map((bucket) => ({ + date: bucket.date, + quarterIndex: bucket.quarterIndex, + input: bucket.input ?? 0, + output: bucket.output ?? bucket.totalTokens, + cacheRead: bucket.cacheRead ?? 0, + cacheWrite: bucket.cacheWrite ?? 0, + totalTokens: bucket.totalTokens, + totalCost: 0, + })), + }, + }) as unknown as UsageSessionEntry; + + it("maps UTC quarter-hour buckets and rejects invalid bucket coordinates", () => { + expect(getHourAndWeekdayForUtcQuarterBucket("2026-02-01", 40, "utc")).toEqual({ + hour: 10, + weekday: 0, + }); + expect(getHourAndWeekdayForUtcQuarterBucket("2026-02-01", -1, "utc")).toBeNull(); + expect(getHourAndWeekdayForUtcQuarterBucket("2026-02-01", 96, "utc")).toBeNull(); + expect(getHourAndWeekdayForUtcQuarterBucket("2026-13-01", 40, "utc")).toBeNull(); + expect(getHourAndWeekdayForUtcQuarterBucket("not-a-date", 40, "utc")).toBeNull(); + }); + + it("uses local timezone mapping for UTC quarter-hour buckets", () => { + vi.spyOn(Date.prototype, "getHours").mockImplementation(function (this: Date) { + return (this.getUTCHours() + 8) % 24; + }); + vi.spyOn(Date.prototype, "getDay").mockReturnValue(1); + + expect(getHourAndWeekdayForUtcQuarterBucket("2026-02-01", 68, "local")).toEqual({ + hour: 1, + weekday: 1, + }); + }); + + it("uses precise token buckets instead of spreading session totals across the session span", () => { + const session = makeSessionWithTokenBuckets([ + { date: "2026-02-01", quarterIndex: 40, totalTokens: 10_000 }, + ]); + + const stats = buildUsageMosaicStats([session], "utc"); + + expect(stats.totalTokens).toBe(10_000); + expect(stats.hourTotals[10]).toBe(10_000); + expect(stats.hourTotals[11]).toBe(0); + }); + + it("filters selected hours by precise token buckets before falling back to session span", () => { + const session = makeSessionWithTokenBuckets([ + { date: "2026-02-01", quarterIndex: 40, totalTokens: 10_000 }, + ]); + + expect(sessionTouchesSelectedHours(session, [10], "utc")).toBe(true); + expect(sessionTouchesSelectedHours(session, [11], "utc")).toBe(false); + }); + + it("preserves legacy session-span hour filtering when token buckets are absent", () => { + const session = { + key: "legacy-span-session", + usage: { + totalTokens: 100, + totalCost: 0, + input: 0, + output: 100, + cacheRead: 0, + cacheWrite: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + firstActivity: Date.parse("2026-02-01T10:00:00.000Z"), + lastActivity: Date.parse("2026-02-01T11:00:00.000Z"), + }, + } as unknown as UsageSessionEntry; + + expect(sessionTouchesSelectedHours(session, [10], "utc")).toBe(true); + expect(sessionTouchesSelectedHours(session, [11], "utc")).toBe(true); + expect(sessionTouchesSelectedHours(session, [12], "utc")).toBe(false); + }); + + it("falls back to session span when token buckets contain no valid positive tokens", () => { + const session = { + key: "empty-token-bucket-session", + usage: { + totalTokens: 100, + totalCost: 0, + input: 0, + output: 100, + cacheRead: 0, + cacheWrite: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + firstActivity: Date.parse("2026-02-01T11:00:00.000Z"), + lastActivity: Date.parse("2026-02-01T11:00:00.000Z"), + utcQuarterHourTokenUsage: [ + { + date: "2026-02-01", + quarterIndex: 40, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + totalCost: 0, + }, + ], + }, + } as unknown as UsageSessionEntry; + + expect(sessionTouchesSelectedHours(session, [11], "utc")).toBe(true); + }); +}); diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index 93594c2940c..aa862f4a288 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -90,15 +90,16 @@ function buildPeakErrorHours(sessions: UsageSessionEntry[], timeZone: "local" | // 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; + const mapped = getHourAndWeekdayForUtcQuarterBucket( + quarterHour.date, + quarterHour.quarterIndex, + timeZone, + ); + if (!mapped) { + continue; + } + hourErrors[mapped.hour] += quarterHour.errors; + hourMsgs[mapped.hour] += quarterHour.total; } continue; } @@ -146,6 +147,42 @@ function getZonedWeekday(date: Date, zone: "local" | "utc"): number { return zone === "utc" ? date.getUTCDay() : date.getDay(); } +function getUtcQuarterHourBucketDate(dateStr: string, quarterIndex: number): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!match || !Number.isInteger(quarterIndex) || quarterIndex < 0 || quarterIndex > 95) { + return null; + } + const [, yStr, mStr, dStr] = match; + const y = Number(yStr); + const m = Number(mStr); + const d = Number(dStr); + const date = new Date(Date.UTC(y, m - 1, d, 0, quarterIndex * 15)); + if ( + Number.isNaN(date.valueOf()) || + date.getUTCFullYear() !== y || + date.getUTCMonth() !== m - 1 || + date.getUTCDate() !== d + ) { + return null; + } + return date; +} + +function getHourAndWeekdayForUtcQuarterBucket( + dateStr: string, + quarterIndex: number, + timeZone: "local" | "utc", +): { hour: number; weekday: number } | null { + const date = getUtcQuarterHourBucketDate(dateStr, quarterIndex); + if (!date) { + return null; + } + return { + hour: getZonedHour(date, timeZone), + weekday: getZonedWeekday(date, timeZone), + }; +} + function setToHourEnd(date: Date, zone: "local" | "utc"): Date { const next = new Date(date); if (zone === "utc") { @@ -156,6 +193,77 @@ function setToHourEnd(date: Date, zone: "local" | "utc"): Date { return next; } +function forEachSessionTokenUsageBucket( + session: UsageSessionEntry, + timeZone: "local" | "utc", + visitor: (params: { hour: number; weekday: number; tokens: number }) => void, +): boolean { + const buckets = session.usage?.utcQuarterHourTokenUsage; + if (!buckets || buckets.length === 0) { + return false; + } + let visited = false; + for (const bucket of buckets) { + if (bucket.totalTokens <= 0) { + continue; + } + const mapped = getHourAndWeekdayForUtcQuarterBucket(bucket.date, bucket.quarterIndex, timeZone); + if (!mapped) { + continue; + } + visited = true; + visitor({ hour: mapped.hour, weekday: mapped.weekday, tokens: bucket.totalTokens }); + } + return visited; +} + +function sessionSpanTouchesSelectedHours( + session: UsageSessionEntry, + hours: number[], + timeZone: "local" | "utc", +): boolean { + const usage = session.usage; + const start = usage?.firstActivity ?? session.updatedAt; + const end = usage?.lastActivity ?? session.updatedAt; + if (!start || !end) { + return false; + } + const startMs = Math.min(start, end); + const endMs = Math.max(start, end); + let cursor = startMs; + while (cursor <= endMs) { + const date = new Date(cursor); + const hour = getZonedHour(date, timeZone); + if (hours.includes(hour)) { + return true; + } + const nextHour = setToHourEnd(date, timeZone); + const nextMs = Math.min(nextHour.getTime(), endMs); + cursor = nextMs + 1; + } + return false; +} + +function sessionTouchesSelectedHours( + session: UsageSessionEntry, + hours: number[], + timeZone: "local" | "utc", +): boolean { + if (hours.length === 0) { + return true; + } + let touches = false; + const hasPreciseTokenBuckets = forEachSessionTokenUsageBucket(session, timeZone, ({ hour }) => { + if (hours.includes(hour)) { + touches = true; + } + }); + if (hasPreciseTokenBuckets) { + return touches; + } + return sessionSpanTouchesSelectedHours(session, hours, timeZone); +} + function buildUsageMosaicStats( sessions: UsageSessionEntry[], timeZone: "local" | "utc", @@ -172,6 +280,16 @@ function buildUsageMosaicStats( } totalTokens += usage.totalTokens; + if ( + forEachSessionTokenUsageBucket(session, timeZone, ({ hour, weekday, tokens }) => { + hourTotals[hour] += tokens; + weekdayTotals[weekday] += tokens; + }) + ) { + hasData = true; + continue; + } + if ( !forEachSessionHourSlice(session, timeZone, ({ usage, hour, weekday, share }) => { hourTotals[hour] += usage.totalTokens * share; @@ -640,10 +758,13 @@ export { formatCost, formatDayLabel, formatFullDate, + buildUsageMosaicStats, formatHourLabel, formatIsoDate, formatTokens, + getHourAndWeekdayForUtcQuarterBucket, getZonedHour, renderUsageMosaic, + sessionTouchesSelectedHours, setToHourEnd, }; diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts index f83c5b45904..23879d46f31 100644 --- a/ui/src/ui/views/usage.ts +++ b/ui/src/ui/views/usage.ts @@ -8,9 +8,8 @@ import { formatCost, formatIsoDate, formatTokens, - getZonedHour, renderUsageMosaic, - setToHourEnd, + sessionTouchesSelectedHours, } from "./usage-metrics.ts"; import { addQueryToken, @@ -171,35 +170,11 @@ export function renderUsage(props: UsageProps) { }) : sortedSessions; - const sessionTouchesHours = (session: UsageSessionEntry, hours: number[]): boolean => { - if (hours.length === 0) { - return true; - } - const usage = session.usage; - const start = usage?.firstActivity ?? session.updatedAt; - const end = usage?.lastActivity ?? session.updatedAt; - if (!start || !end) { - return false; - } - const startMs = Math.min(start, end); - const endMs = Math.max(start, end); - let cursor = startMs; - while (cursor <= endMs) { - const date = new Date(cursor); - const hour = getZonedHour(date, filters.timeZone); - if (hours.includes(hour)) { - return true; - } - const nextHour = setToHourEnd(date, filters.timeZone); - const nextMs = Math.min(nextHour.getTime(), endMs); - cursor = nextMs + 1; - } - return false; - }; - const hourFilteredSessions = filters.selectedHours.length > 0 - ? dayFilteredSessions.filter((s) => sessionTouchesHours(s, filters.selectedHours)) + ? dayFilteredSessions.filter((s) => + sessionTouchesSelectedHours(s, filters.selectedHours, filters.timeZone), + ) : dayFilteredSessions; // Filter sessions by query (client-side)