refactor(usage): add precise token buckets for Usage Mosaic (#74337)

Merged via squash.

Prepared head SHA: 15185354c4
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:
konanok
2026-04-30 23:32:34 +08:00
committed by GitHub
parent 9289a502bb
commit 0eb8f34000
7 changed files with 518 additions and 70 deletions

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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<string, { tokens: number; cost: number }>();
const dailyMessageMap = new Map<string, SessionDailyMessageCounts>();
const utcQuarterHourMessageMap = new Map<string, SessionUtcQuarterHourMessageCounts>();
const utcQuarterHourTokenMap = new Map<string, SessionUtcQuarterHourTokenUsage>();
const dailyLatencyMap = new Map<string, number[]>();
const dailyModelUsageMap = new Map<string, SessionDailyModelUsage>();
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;

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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,
};

View File

@@ -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)