refactor: rename hourlyMessageCounts to utcQuarterHourMessageCounts

This commit is contained in:
Mason Huang
2026-04-29 14:51:30 +08:00
parent 0dcb7aacfc
commit 8c70216119
5 changed files with 41 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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