mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user