fix: keep usage day buckets correct across DST (#103097)

* fix: bucket usage days across DST

* docs: document DST-aware usage dates

* perf: reuse usage day formatters

* fix: preserve usage fallback on older gateways

* docs: normalize usage changelog entry

* docs: leave release changelog unchanged

* fix: handle skipped timezone dates
This commit is contained in:
Peter Steinberger
2026-07-09 22:39:57 +01:00
committed by GitHub
parent 90b68536c4
commit db44b284ff
17 changed files with 760 additions and 162 deletions

View File

@@ -3056,6 +3056,7 @@ public struct SessionsUsageParams: Codable, Sendable {
public let groupby: AnyCodable?
public let includehistorical: Bool?
public let utcoffset: String?
public let timezone: String?
public let limit: Int?
public let includecontextweight: Bool?
@@ -3070,6 +3071,7 @@ public struct SessionsUsageParams: Codable, Sendable {
groupby: AnyCodable?,
includehistorical: Bool?,
utcoffset: String?,
timezone: String? = nil,
limit: Int?,
includecontextweight: Bool?)
{
@@ -3083,6 +3085,7 @@ public struct SessionsUsageParams: Codable, Sendable {
self.groupby = groupby
self.includehistorical = includehistorical
self.utcoffset = utcoffset
self.timezone = timezone
self.limit = limit
self.includecontextweight = includecontextweight
}
@@ -3098,6 +3101,7 @@ public struct SessionsUsageParams: Codable, Sendable {
case groupby = "groupBy"
case includehistorical = "includeHistorical"
case utcoffset = "utcOffset"
case timezone = "timeZone"
case limit
case includecontextweight = "includeContextWeight"
}

View File

@@ -359,6 +359,7 @@ methods. Treat this as feature discovery, not a full enumeration of
- `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients, including workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates. Requires `operator.read`.
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together.
Both usage methods accept `mode: "specific"` with an IANA `timeZone` for DST-aware calendar-day boundaries and buckets. `utcOffset` remains supported for older clients and as a fallback when the Gateway runtime does not recognize the requested zone.
- `sessions.usage.timeseries` returns timeseries usage for one session.
- `sessions.usage.logs` returns usage log entries for one session.

View File

@@ -15,6 +15,7 @@ import {
validateNodeEventResult,
validateNodePairRequestParams,
validateNodePresenceAlivePayload,
validateSessionsUsageParams,
validateTasksCancelParams,
validateTasksListParams,
validateTalkCatalogResult,
@@ -136,6 +137,15 @@ describe("lazy protocol validators", () => {
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
});
it("accepts an IANA time zone for session usage while retaining UTC offsets", () => {
expect(validateSessionsUsageParams({ mode: "specific", timeZone: "Europe/Vienna" })).toBe(
true,
);
expect(validateSessionsUsageParams({ mode: "specific", utcOffset: "UTC+2" })).toBe(true);
expect(validateSessionsUsageParams({ mode: "specific", timeZone: "" })).toBe(false);
expect(validateSessionsUsageParams({ mode: "specific", timeZone: 2 })).toBe(false);
});
it("validates chat sends that suppress command interpretation", () => {
expect(
validateChatSendParams({

View File

@@ -580,6 +580,8 @@ export const SessionsUsageParamsSchema = Type.Object(
includeHistorical: Type.Optional(Type.Boolean()),
/** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */
utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })),
/** IANA time zone for `specific`; preferred over `utcOffset`, which remains a compatibility fallback. */
timeZone: Type.Optional(NonEmptyString),
/** Maximum sessions to return (default 50). */
limit: Type.Optional(Type.Integer({ minimum: 1 })),
/** Include context weight breakdown (systemPromptReport). */

View File

@@ -62,7 +62,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
],
["SessionsCompactParams", ["agentId"]],
["SessionsResolveParams", ["allowMissing"]],
["SessionsUsageParams", ["agentId", "agentScope"]],
["SessionsUsageParams", ["agentId", "agentScope", "timeZone"]],
["ChatHistoryParams", ["agentId", "offset"]],
["ChatSendParams", ["agentId"]],
["ChatAbortParams", ["agentId", "preserveSideRuns"]],

View File

@@ -340,10 +340,50 @@ describe("sessions.usage", () => {
});
expect(vi.mocked(loadSessionCostSummariesFromCache)).toHaveBeenCalledWith(
expect.objectContaining({ dailyUtcOffsetMinutes: -300 }),
expect.objectContaining({
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -300 },
}),
);
});
it("falls back to the legacy offset when Gateway ICU does not recognize the browser timezone", async () => {
await runSessionsUsage({
...BASE_USAGE_RANGE,
mode: "specific",
timeZone: "Newer/BrowserZone",
utcOffset: "UTC-5",
});
expect(vi.mocked(loadSessionCostSummariesFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -300 },
}),
);
});
it("uses an IANA timezone for session range boundaries, labels, and daily summaries", async () => {
const respond = await runSessionsUsage({
...BASE_USAGE_RANGE,
startDate: "2026-10-25",
endDate: "2026-10-25",
mode: "specific",
timeZone: "Europe/Vienna",
// The zone takes precedence and changes offset during this local day.
utcOffset: "UTC+2",
});
expect(vi.mocked(loadSessionCostSummariesFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
startMs: Date.UTC(2026, 9, 24, 22),
endMs: Date.UTC(2026, 9, 25, 23) - 1,
dayBucket: { mode: "time-zone", timeZone: "Europe/Vienna" },
}),
);
const result = mockArg(respond, 0, 1) as { startDate: string; endDate: string };
expect(result.startDate).toBe("2026-10-25");
expect(result.endDate).toBe("2026-10-25");
});
it("formats response date labels in the requested timezone offset", async () => {
const respond = await runSessionsUsage({
...BASE_USAGE_RANGE,

View File

@@ -171,6 +171,39 @@ describe("gateway usage helpers", () => {
expect(vi.mocked(loadCostUsageSummaryFromCache)).not.toHaveBeenCalled();
});
it.each(["usage.cost", "sessions.usage"] as const)(
"%s rejects an invalid IANA timezone with INVALID_REQUEST",
async (method) => {
const respond = vi.fn();
await usageHandlers[method]({
respond,
params: { mode: "specific", timeZone: "Invalid/Timezone" },
context: { getRuntimeConfig: vi.fn(() => ({})) },
} as unknown as Parameters<(typeof usageHandlers)[typeof method]>[0]);
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(JSON.stringify(respond.mock.calls[0]?.[2])).toContain("invalid timeZone");
expect(vi.mocked(loadCostUsageSummaryFromCache)).not.toHaveBeenCalled();
},
);
it("falls back to the legacy offset when Gateway ICU does not recognize the browser timezone", async () => {
const respond = vi.fn();
await usageHandlers["usage.cost"]({
respond,
params: { mode: "specific", timeZone: "Newer/BrowserZone", utcOffset: "UTC+1" },
context: { getRuntimeConfig: vi.fn(() => ({})) },
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
dayBucket: { mode: "utc-offset", utcOffsetMinutes: 60 },
}),
);
});
it.each(["usage.cost", "sessions.usage"] as const)(
"%s rejects startDate after endDate with INVALID_REQUEST",
async (method) => {
@@ -243,6 +276,47 @@ describe("gateway usage helpers", () => {
expect(range.endMs).toBe(endStart + dayMs - 1);
});
it("resolveDateRange uses IANA timezone boundaries across a DST transition", () => {
const range = expectDateRange(
testApi.resolveDateRange({
startDate: "2026-10-25",
endDate: "2026-10-25",
mode: "specific",
timeZone: "Europe/Vienna",
// The IANA zone must take precedence over this pre-transition fixed offset.
utcOffset: "UTC+2",
}),
);
expect(range.startMs).toBe(Date.UTC(2026, 9, 24, 22));
expect(range.endMs).toBe(Date.UTC(2026, 9, 25, 23) - 1);
});
it("resolveDateRange crosses a skipped IANA civil date for the prior day's end", () => {
const range = expectDateRange(
testApi.resolveDateRange({
startDate: "2011-12-29",
endDate: "2011-12-29",
mode: "specific",
timeZone: "Pacific/Apia",
}),
);
expect(range.startMs).toBe(Date.parse("2011-12-29T10:00:00.000Z"));
expect(range.endMs).toBe(Date.parse("2011-12-30T10:00:00.000Z") - 1);
expect(
testApi.resolveDateRange({
startDate: "2011-12-30",
endDate: "2011-12-30",
mode: "specific",
timeZone: "Pacific/Apia",
}),
).toEqual({
ok: false,
error: "calendar day does not exist in requested time zone",
});
});
it("resolveDateRange falls back to UTC when specific mode offset is missing or invalid", () => {
const missingOffset = expectDateRange(
testApi.resolveDateRange({
@@ -384,29 +458,41 @@ describe("gateway usage helpers", () => {
});
});
it("keeps cost usage cache entries scoped by daily timezone offset", async () => {
it("keeps cost usage cache entries scoped by the complete day bucket", async () => {
const config = {} as OpenClawConfig;
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: 0,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: 0 },
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: -300,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -300 },
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: 0,
dayBucket: { mode: "time-zone", timeZone: "America/New_York" },
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: 0 },
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dayBucket: { mode: "time-zone", timeZone: "America/New_York" },
config,
});
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(2);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(3);
});
it("passes usage.cost agentId through to the cost summary loader", async () => {
@@ -439,7 +525,33 @@ describe("gateway usage helpers", () => {
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({ dailyUtcOffsetMinutes: -300 }),
expect.objectContaining({
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -300 },
}),
);
});
it("uses an IANA timezone for usage.cost range boundaries and day buckets", async () => {
const respond = vi.fn();
await usageHandlers["usage.cost"]({
respond,
params: {
startDate: "2026-10-25",
endDate: "2026-10-25",
mode: "specific",
timeZone: "Europe/Vienna",
utcOffset: "UTC+2",
},
context: { getRuntimeConfig: () => ({}) },
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
startMs: Date.UTC(2026, 9, 24, 22),
endMs: Date.UTC(2026, 9, 25, 23) - 1,
dayBucket: { mode: "time-zone", timeZone: "Europe/Vienna" },
}),
);
});

View File

@@ -15,6 +15,11 @@ import {
} from "../../config/sessions/paths.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
createTimeZoneDayKeyFormatter,
resolveTimezone,
resolveTimeZoneDayStartMs,
} from "../../infra/format-time/format-datetime.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import {
addCostUsageTotals,
@@ -36,6 +41,7 @@ import {
discoverAllSessions,
resolveExistingUsageSessionFile,
type DiscoveredSession,
type UsageDailyBucket,
type UsageCacheStatus,
} from "../../infra/session-cost-usage.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
@@ -86,9 +92,15 @@ type DateRange = { startMs: number; endMs: number };
type DateRangeResolution = { ok: true; value: DateRange } | { ok: false; error: string };
type DateInterpretation =
| { mode: "utc" | "gateway" }
| { mode: "specific"; utcOffsetMinutes: number };
| { mode: "utc-offset"; utcOffsetMinutes: number }
| { mode: "time-zone"; timeZone: string; formatDayKey: (date: Date) => string };
type DateInterpretationResolution =
| { ok: true; value: DateInterpretation }
| { ok: false; error: string };
type DateParts = { year: number; monthIndex: number; day: number };
const MAX_CONSECUTIVE_SKIPPED_TIME_ZONE_DAYS = 1;
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
updatedAt?: number;
@@ -191,19 +203,42 @@ const shiftDateParts = (parts: DateParts, days: number): DateParts => {
};
};
const datePartsToStartMs = (parts: DateParts, interpretation: DateInterpretation): number => {
const datePartsToStartMs = (
parts: DateParts,
interpretation: DateInterpretation,
): number | undefined => {
const { year, monthIndex, day } = parts;
if (interpretation.mode === "gateway") {
return new Date(year, monthIndex, day).getTime();
}
if (interpretation.mode === "specific") {
if (interpretation.mode === "time-zone") {
return resolveTimeZoneDayStartMs(
formatDateParts(year, monthIndex, day),
interpretation.timeZone,
);
}
if (interpretation.mode === "utc-offset") {
return Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
}
return Date.UTC(year, monthIndex, day);
};
const datePartsToEndMs = (parts: DateParts, interpretation: DateInterpretation): number =>
datePartsToStartMs(shiftDateParts(parts, 1), interpretation) - 1;
const datePartsToEndMs = (
parts: DateParts,
interpretation: DateInterpretation,
): number | undefined => {
const lookaheadDays =
interpretation.mode === "time-zone" ? 1 + MAX_CONSECUTIVE_SKIPPED_TIME_ZONE_DAYS : 1;
// A 24-hour date-line transition can remove one civil date entirely. Range
// resolution separately verifies the requested day; this only finds its end.
for (let daysAhead = 1; daysAhead <= lookaheadDays; daysAhead += 1) {
const nextDayStartMs = datePartsToStartMs(shiftDateParts(parts, daysAhead), interpretation);
if (nextDayStartMs !== undefined) {
return nextDayStartMs - 1;
}
}
return undefined;
};
// usage.cost / sessions.usage accept optional startDate/endDate. parseDateParts returns
// undefined for both absent and invalid input, so an explicitly supplied but unparseable
@@ -257,32 +292,66 @@ const parseUtcOffsetToMinutes = (raw: unknown): number | undefined => {
const resolveDateInterpretation = (params: {
mode?: unknown;
utcOffset?: unknown;
}): DateInterpretation => {
timeZone?: unknown;
}): DateInterpretationResolution => {
if (params.mode === "gateway") {
return { mode: "gateway" };
return { ok: true, value: { mode: "gateway" } };
}
if (params.mode === "specific") {
const utcOffsetMinutes = parseUtcOffsetToMinutes(params.utcOffset);
if (params.timeZone !== undefined && params.timeZone !== null) {
const requestedTimeZone = normalizeOptionalString(params.timeZone);
const timeZone = requestedTimeZone ? resolveTimezone(requestedTimeZone) : undefined;
if (!timeZone) {
// Browser tzdata can lead Gateway ICU. Preserve legacy fixed-offset
// reporting when the concurrently supplied offset is still usable.
if (utcOffsetMinutes !== undefined) {
return { ok: true, value: { mode: "utc-offset", utcOffsetMinutes } };
}
return { ok: false, error: "invalid timeZone: expected a valid IANA time zone" };
}
return {
ok: true,
value: {
mode: "time-zone",
timeZone,
formatDayKey: createTimeZoneDayKeyFormatter(timeZone),
},
};
}
if (utcOffsetMinutes !== undefined) {
return { mode: "specific", utcOffsetMinutes };
return { ok: true, value: { mode: "utc-offset", utcOffsetMinutes } };
}
}
// Backward compatibility: when mode is missing (or invalid), keep current UTC interpretation.
return { mode: "utc" };
return { ok: true, value: { mode: "utc" } };
};
const resolveDayBucketUtcOffsetMinutes = (interpretation: DateInterpretation) =>
interpretation.mode === "gateway"
? undefined
: interpretation.mode === "specific"
? interpretation.utcOffsetMinutes
: 0;
const resolveDayBucket = (interpretation: DateInterpretation): UsageDailyBucket | undefined => {
if (interpretation.mode === "gateway") {
return undefined;
}
if (interpretation.mode === "time-zone") {
return { mode: "time-zone", timeZone: interpretation.timeZone };
}
return {
mode: "utc-offset",
utcOffsetMinutes: interpretation.mode === "utc-offset" ? interpretation.utcOffsetMinutes : 0,
};
};
const getDateParts = (date: Date, interpretation: DateInterpretation): DateParts => {
if (interpretation.mode === "gateway") {
return { year: date.getFullYear(), monthIndex: date.getMonth(), day: date.getDate() };
}
if (interpretation.mode === "specific") {
if (interpretation.mode === "time-zone") {
const parts = parseDateParts(interpretation.formatDayKey(date));
if (!parts) {
throw new Error("timezone formatter returned an invalid calendar day");
}
return parts;
}
if (interpretation.mode === "utc-offset") {
const shifted = new Date(date.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
return {
year: shifted.getUTCFullYear(),
@@ -356,23 +425,31 @@ const resolveTrailingDays = (
endDateParts: DateParts,
days: number,
interpretation: DateInterpretation,
): DateRange => ({
startMs: datePartsToStartMs(shiftDateParts(endDateParts, -(days - 1)), interpretation),
endMs: datePartsToEndMs(endDateParts, interpretation),
});
): DateRangeResolution => {
const startMs = datePartsToStartMs(shiftDateParts(endDateParts, -(days - 1)), interpretation);
const endMs = datePartsToEndMs(endDateParts, interpretation);
if (startMs === undefined || endMs === undefined) {
return { ok: false, error: "calendar day does not exist in requested time zone" };
}
return { ok: true, value: { startMs, endMs } };
};
/**
* Get date range from params (startDate/endDate or days).
* Falls back to last 30 days if not provided.
*/
const resolveDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
range?: unknown;
mode?: unknown;
utcOffset?: unknown;
}): DateRangeResolution => {
const resolveDateRange = (
params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
range?: unknown;
mode?: unknown;
utcOffset?: unknown;
timeZone?: unknown;
},
resolvedInterpretation?: DateInterpretation,
): DateRangeResolution => {
const invalidDate = findInvalidExplicitDate(params);
if (invalidDate) {
return {
@@ -382,9 +459,18 @@ const resolveDateRange = (params: {
}
const now = new Date();
const interpretation = resolveDateInterpretation(params);
const interpretationResolution = resolvedInterpretation
? { ok: true as const, value: resolvedInterpretation }
: resolveDateInterpretation(params);
if (!interpretationResolution.ok) {
return interpretationResolution;
}
const interpretation = interpretationResolution.value;
const todayDateParts = getDateParts(now, interpretation);
const todayEndMs = datePartsToEndMs(todayDateParts, interpretation);
if (todayEndMs === undefined) {
return { ok: false, error: "calendar day does not exist in requested time zone" };
}
const startDateParts = parseDateParts(params.startDate);
const endDateParts = parseDateParts(params.endDate);
@@ -392,10 +478,14 @@ const resolveDateRange = (params: {
if (startDateParts && endDateParts) {
const startMs = datePartsToStartMs(startDateParts, interpretation);
const endStartMs = datePartsToStartMs(endDateParts, interpretation);
const endMs = datePartsToEndMs(endDateParts, interpretation);
if (startMs === undefined || endStartMs === undefined || endMs === undefined) {
return { ok: false, error: "calendar day does not exist in requested time zone" };
}
if (startMs > endStartMs) {
return { ok: false, error: "startDate must not be after endDate" };
}
return { ok: true, value: { startMs, endMs: datePartsToEndMs(endDateParts, interpretation) } };
return { ok: true, value: { startMs, endMs } };
}
const rangeDays = resolveRangeDays(params.range);
@@ -403,17 +493,17 @@ const resolveDateRange = (params: {
return { ok: true, value: { startMs: 0, endMs: todayEndMs } };
}
if (rangeDays !== undefined) {
return { ok: true, value: resolveTrailingDays(todayDateParts, rangeDays, interpretation) };
return resolveTrailingDays(todayDateParts, rangeDays, interpretation);
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
return { ok: true, value: resolveTrailingDays(todayDateParts, clampedDays, interpretation) };
return resolveTrailingDays(todayDateParts, clampedDays, interpretation);
}
// Default to last 30 days
return { ok: true, value: resolveTrailingDays(todayDateParts, 30, interpretation) };
return resolveTrailingDays(todayDateParts, 30, interpretation);
};
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
@@ -794,13 +884,17 @@ function mergeDailyModelRows(
async function loadCostUsageSummaryCached(params: {
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
config: OpenClawConfig;
agentId?: string;
agentScope?: "all";
}): Promise<CostUsageSummary> {
const dailyOffsetKey = params.dailyUtcOffsetMinutes ?? "gateway";
const cacheKey = `${params.agentScope === "all" ? "all" : `agent:${params.agentId ?? "__default__"}`}:${params.startMs}-${params.endMs}:${dailyOffsetKey}`;
const dayBucketKey = params.dayBucket
? params.dayBucket.mode === "time-zone"
? `time-zone:${params.dayBucket.timeZone}`
: `utc-offset:${params.dayBucket.utcOffsetMinutes}`
: "gateway";
const cacheKey = `${params.agentScope === "all" ? "all" : `agent:${params.agentId ?? "__default__"}`}:${params.startMs}-${params.endMs}:${dayBucketKey}`;
const now = Date.now();
const cached = costUsageCache.get(cacheKey);
if (
@@ -825,13 +919,13 @@ async function loadCostUsageSummaryCached(params: {
? loadAllAgentCostUsageSummary({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
dayBucket: params.dayBucket,
config: params.config,
})
: loadCostUsageSummaryFromCache({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
dayBucket: params.dayBucket,
config: params.config,
agentId: params.agentId,
requestRefresh: true,
@@ -872,7 +966,7 @@ async function loadCostUsageSummaryCached(params: {
async function loadAllAgentCostUsageSummary(params: {
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
config: OpenClawConfig;
}): Promise<CostUsageSummary> {
const agentIds = listAgentsForGateway(params.config).agents.map((agent) =>
@@ -884,7 +978,7 @@ async function loadAllAgentCostUsageSummary(params: {
loadCostUsageSummaryFromCache({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
dayBucket: params.dayBucket,
config: params.config,
agentId,
requestRefresh: true,
@@ -966,18 +1060,32 @@ export const usageHandlers: GatewayRequestHandlers = {
respond(true, summary, undefined);
},
"usage.cost": async ({ respond, params, context }) => {
const dateInterpretation = resolveDateInterpretation({
mode: params?.mode,
utcOffset: params?.utcOffset,
});
const dateRange = resolveDateRange({
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
range: params?.range,
const dateInterpretationResolution = resolveDateInterpretation({
mode: params?.mode,
utcOffset: params?.utcOffset,
timeZone: params?.timeZone,
});
if (!dateInterpretationResolution.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, dateInterpretationResolution.error),
);
return;
}
const dateInterpretation = dateInterpretationResolution.value;
const dateRange = resolveDateRange(
{
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
range: params?.range,
mode: params?.mode,
utcOffset: params?.utcOffset,
timeZone: params?.timeZone,
},
dateInterpretation,
);
if (!dateRange.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, dateRange.error));
return;
@@ -989,7 +1097,7 @@ export const usageHandlers: GatewayRequestHandlers = {
const summary = await loadCostUsageSummaryCached({
startMs,
endMs,
dailyUtcOffsetMinutes: resolveDayBucketUtcOffsetMinutes(dateInterpretation),
dayBucket: resolveDayBucket(dateInterpretation),
config,
agentId,
agentScope,
@@ -1010,21 +1118,38 @@ export const usageHandlers: GatewayRequestHandlers = {
}
const p = params;
const dateRange = resolveDateRange({
startDate: p.startDate,
endDate: p.endDate,
range: p.range,
const dateInterpretationResolution = resolveDateInterpretation({
mode: p.mode,
utcOffset: p.utcOffset,
timeZone: p.timeZone,
});
if (!dateInterpretationResolution.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, dateInterpretationResolution.error),
);
return;
}
const dateInterpretation = dateInterpretationResolution.value;
const dateRange = resolveDateRange(
{
startDate: p.startDate,
endDate: p.endDate,
range: p.range,
mode: p.mode,
utcOffset: p.utcOffset,
timeZone: p.timeZone,
},
dateInterpretation,
);
if (!dateRange.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, dateRange.error));
return;
}
const config = context.getRuntimeConfig();
const { startMs, endMs } = dateRange.value;
const dateInterpretation = resolveDateInterpretation({ mode: p.mode, utcOffset: p.utcOffset });
const dailyUtcOffsetMinutes = resolveDayBucketUtcOffsetMinutes(dateInterpretation);
const dayBucket = resolveDayBucket(dateInterpretation);
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = normalizeOptionalString(p.key) ?? null;
@@ -1292,7 +1417,7 @@ export const usageHandlers: GatewayRequestHandlers = {
agentId,
startMs,
endMs,
dailyUtcOffsetMinutes,
dayBucket,
}),
})),
);

View File

@@ -17,6 +17,57 @@ export function resolveTimezone(value: string): string | undefined {
}
}
/** Build a stable YYYY-MM-DD formatter for instants in one IANA timezone. */
export function createTimeZoneDayKeyFormatter(timeZone: string): (date: Date) => string {
const formatter = new Intl.DateTimeFormat("en-US-u-ca-iso8601-nu-latn", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return (date) => {
const parts = formatter.formatToParts(date);
const pick = (type: "year" | "month" | "day") =>
parts.find((part) => part.type === type)?.value;
const year = pick("year");
const month = pick("month");
const day = pick("day");
if (!year || !month || !day) {
throw new Error("Intl.DateTimeFormat omitted required calendar-day parts");
}
return `${year.padStart(4, "0")}-${month}-${day}`;
};
}
/** Resolve the first instant belonging to a calendar day in an IANA timezone. */
export function resolveTimeZoneDayStartMs(dayKey: string, timeZone: string): number | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
if (!match) {
return undefined;
}
const naiveUtcMs = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
if (!Number.isFinite(naiveUtcMs)) {
return undefined;
}
const formatDayKey = createTimeZoneDayKeyFormatter(timeZone);
const searchWindowMs = 2 * 24 * 60 * 60 * 1000;
let low = naiveUtcMs - searchWindowMs;
let high = naiveUtcMs + searchWindowMs;
// Calendar-day labels are monotonic across this bounded window. Find the
// first millisecond whose local label is the requested day, including days
// whose first wall-clock time is not 00:00 because of an offset transition.
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (formatDayKey(new Date(middle)) < dayKey) {
low = middle + 1;
} else {
high = middle;
}
}
return formatDayKey(new Date(low)) === dayKey ? low : undefined;
}
type FormatTimestampOptions = {
/** Include seconds in the output. Default: false */
displaySeconds?: boolean;

View File

@@ -1,6 +1,12 @@
// Covers duration, UTC/zoned timestamp, timezone, and relative time formatting.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js";
import {
createTimeZoneDayKeyFormatter,
formatUtcTimestamp,
formatZonedTimestamp,
resolveTimeZoneDayStartMs,
resolveTimezone,
} from "./format-datetime.js";
import {
formatDurationCompact,
formatDurationHuman,
@@ -130,6 +136,24 @@ describe("format-datetime", () => {
});
});
describe("calendar days", () => {
it("formats event instants with the offset active in the requested timezone", () => {
const formatViennaDay = createTimeZoneDayKeyFormatter("Europe/Vienna");
expect(formatViennaDay(new Date("2026-03-28T22:30:00.000Z"))).toBe("2026-03-28");
expect(formatViennaDay(new Date("2026-03-29T22:30:00.000Z"))).toBe("2026-03-30");
});
it("resolves calendar boundaries across a DST-short day", () => {
const start = resolveTimeZoneDayStartMs("2026-03-29", "Europe/Vienna");
const next = resolveTimeZoneDayStartMs("2026-03-30", "Europe/Vienna");
expect(start).toBe(Date.parse("2026-03-28T23:00:00.000Z"));
expect(next).toBe(Date.parse("2026-03-29T22:00:00.000Z"));
expect(next! - start!).toBe(23 * 60 * 60 * 1000);
});
});
describe("formatUtcTimestamp", () => {
it.each([
{ displaySeconds: false, expected: "2024-01-15T14:30Z" },

View File

@@ -11,6 +11,7 @@ import {
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import * as usageFormat from "../utils/usage-format.js";
import * as formatDatetime from "./format-time/format-datetime.js";
import {
discoverAllSessions,
loadCostUsageSummary,
@@ -646,15 +647,33 @@ describe("session cost usage", () => {
await withStateDir(root, async () => {
await refreshCostUsageCache({ sessionFiles: sessionFiles.map((entry) => entry.sessionFile) });
const result = await loadSessionCostSummariesFromCache({
sessions: sessionFiles,
agentId: "main",
startMs: Date.UTC(2026, 1, 5),
endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1,
});
const createDayFormatter = formatDatetime.createTimeZoneDayKeyFormatter;
let formatDayKeyCalls = 0;
const dayFormatterSpy = vi
.spyOn(formatDatetime, "createTimeZoneDayKeyFormatter")
.mockImplementation((timeZone) => {
const formatDayKey = createDayFormatter(timeZone);
return (date) => {
formatDayKeyCalls += 1;
return formatDayKey(date);
};
});
try {
const result = await loadSessionCostSummariesFromCache({
sessions: sessionFiles,
agentId: "main",
startMs: Date.UTC(2026, 1, 5),
endMs: Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1,
dayBucket: { mode: "time-zone", timeZone: "Europe/Vienna" },
});
expect(result.cacheStatus.status).toBe("fresh");
expect(result.summaries.map((summary) => summary?.totalTokens)).toEqual([1, 2]);
expect(result.cacheStatus.status).toBe("fresh");
expect(result.summaries.map((summary) => summary?.totalTokens)).toEqual([1, 2]);
expect(dayFormatterSpy).toHaveBeenCalledTimes(1);
expect(formatDayKeyCalls).toBe(2);
} finally {
dayFormatterSpy.mockRestore();
}
});
});
@@ -694,7 +713,7 @@ describe("session cost usage", () => {
agentId: "main",
startMs: Date.UTC(2026, 1, 11, 2),
endMs: Date.UTC(2026, 1, 12, 1, 59, 59, 999),
dailyUtcOffsetMinutes: -120,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -120 },
});
const summary = requireValue(result.summaries[0], "offset session summary missing");
@@ -709,6 +728,86 @@ describe("session cost usage", () => {
});
});
it("rebuckets cost and session days with historical DST offsets", async () => {
const root = await makeSessionCostRoot("cost-cache-request-timezone");
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-timezone.jsonl");
const usage = {
input: 10,
output: 5,
totalTokens: 15,
cost: { total: 0.00001 },
};
await fs.writeFile(
sessionFile,
[
{
type: "message",
timestamp: "2026-03-28T22:29:00.000Z",
message: { role: "user", content: "before DST" },
},
{
type: "message",
timestamp: "2026-03-28T22:30:00.000Z",
message: { role: "assistant", usage },
},
{
type: "message",
timestamp: "2026-03-29T22:29:00.000Z",
message: { role: "user", content: "after DST" },
},
{
type: "message",
timestamp: "2026-03-29T22:30:00.000Z",
message: { role: "assistant", usage },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
"utf-8",
);
await withStateDir(root, async () => {
await refreshCostUsageCache({ agentId: "main", sessionFiles: [sessionFile] });
const dayBucket = { mode: "time-zone", timeZone: "Europe/Vienna" } as const;
const startMs = Date.parse("2026-03-27T23:00:00.000Z");
const endMs = Date.parse("2026-03-30T21:59:59.999Z");
const cost = await loadCostUsageSummaryFromCache({
agentId: "main",
startMs,
endMs,
dayBucket,
requestRefresh: false,
});
const sessions = await loadSessionCostSummariesFromCache({
sessions: [{ sessionId: "sess-timezone", sessionFile }],
agentId: "main",
startMs,
endMs,
dayBucket,
requestRefresh: false,
});
const session = requireValue(sessions.summaries[0], "timezone session summary missing");
expect(cost.days).toBe(3);
expect(cost.daily.map((entry) => [entry.date, entry.totalTokens])).toEqual([
["2026-03-28", 15],
["2026-03-29", 0],
["2026-03-30", 15],
]);
expect(session.activityDates).toEqual(["2026-03-28", "2026-03-30"]);
expect(session.dailyBreakdown?.map((entry) => [entry.date, entry.tokens])).toEqual([
["2026-03-28", 15],
["2026-03-30", 15],
]);
expect(session.dailyMessageCounts?.map((entry) => entry.date)).toEqual([
"2026-03-28",
"2026-03-30",
]);
});
});
it("ignores compaction checkpoint transcript snapshots in daily totals and discovery", async () => {
const root = await makeSessionCostRoot("cost-checkpoint");
const sessionsDir = path.join(root, "agents", "main", "sessions");
@@ -803,7 +902,7 @@ describe("session cost usage", () => {
const summary = await loadCostUsageSummary({
startMs,
endMs,
dailyUtcOffsetMinutes: -120,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -120 },
});
expect(summary.daily.map((entry) => entry.date)).toEqual(["2026-02-11"]);
@@ -1886,7 +1985,7 @@ describe("session cost usage", () => {
sessionFile,
startMs: Date.UTC(2026, 1, 4, 2),
endMs: Date.UTC(2026, 1, 5, 1, 59, 59, 999),
dailyUtcOffsetMinutes: -120,
dayBucket: { mode: "utc-offset", utcOffsetMinutes: -120 },
requestRefresh: false,
refreshMode: "sync-when-empty",
});

View File

@@ -31,6 +31,7 @@ import {
resolveModelCostConfigFingerprint,
} from "../utils/usage-format.js";
import { formatErrorMessage } from "./errors.js";
import { createTimeZoneDayKeyFormatter } from "./format-time/format-datetime.js";
import { replaceFileAtomic } from "./replace-file.js";
import {
addCostUsageTotals as addTotals,
@@ -59,6 +60,7 @@ import type {
SessionUsageTimePoint,
SessionUsageTimeSeries,
UsageCacheStatus,
UsageDailyBucket,
} from "./session-cost-usage.types.js";
export type {
@@ -73,6 +75,7 @@ export type {
SessionModelUsage,
SessionToolUsage,
UsageCacheStatus,
UsageDailyBucket,
} from "./session-cost-usage.types.js";
// Bump when the durable cache schema or the meaning of cached totals changes, so
@@ -475,10 +478,11 @@ function buildCostUsageSummaryFromCache(params: {
files: UsageCostTranscriptFile[];
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
refreshing: boolean;
}): CostUsageSummary {
const dailyMap = new Map<string, CostUsageTotals>();
const formatDayKey = createUsageDayKeyFormatter(params.dayBucket);
const totals = emptyTotals();
const filesByPath = new Map(params.files.map((file) => [file.filePath, file]));
const staleFiles = getUsageCostStaleFiles({
@@ -505,7 +509,7 @@ function buildCostUsageSummaryFromCache(params: {
if (usageEntry.timestamp < params.startMs || usageEntry.timestamp > params.endMs) {
continue;
}
const date = formatDayKey(new Date(usageEntry.timestamp), params.dailyUtcOffsetMinutes);
const date = formatDayKey(new Date(usageEntry.timestamp));
const bucket = dailyMap.get(date) ?? emptyTotals();
addTotals(bucket, usageEntry);
dailyMap.set(date, bucket);
@@ -513,12 +517,12 @@ function buildCostUsageSummaryFromCache(params: {
}
}
fillMissingDays(dailyMap, params.startMs, params.endMs, params.dailyUtcOffsetMinutes);
fillMissingDays(dailyMap, params.startMs, params.endMs, formatDayKey);
const daily = Array.from(dailyMap.entries())
.map(([date, bucket]) => Object.assign({ date }, bucket))
.toSorted((a, b) => a.date.localeCompare(b.date));
const days = Math.ceil((params.endMs - params.startMs) / (24 * 60 * 60 * 1000)) + 1;
const days = countCalendarDays(params.startMs, params.endMs, formatDayKey);
const status = params.refreshing
? "refreshing"
: staleFiles.length > 0
@@ -559,7 +563,7 @@ function buildSessionCostSummaryFromCacheEntry(params: {
sessionFile: string;
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
formatDayKey: UsageDayKeyFormatter;
}): SessionCostSummary | null {
if (!params.entry.transcriptEntries) {
return null;
@@ -572,6 +576,7 @@ function buildSessionCostSummaryFromCacheEntry(params: {
const utcQuarterHourTokenMap = new Map<string, SessionUtcQuarterHourTokenUsage>();
const dailyLatencyMap = new Map<string, number[]>();
const dailyModelUsageMap = new Map<string, SessionDailyModelUsage>();
const formatDayKey = params.formatDayKey;
const messageCounts: SessionMessageCounts = {
total: 0,
user: 0,
@@ -597,6 +602,9 @@ function buildSessionCostSummaryFromCacheEntry(params: {
if (ts !== undefined && ts > params.endMs) {
continue;
}
const date = ts === undefined ? undefined : new Date(ts);
const dayKey = date ? formatDayKey(date) : undefined;
const quarterBucket = date ? getUtcQuarterHourBucketKey(date) : undefined;
if (ts !== undefined) {
firstActivity = firstActivity === undefined ? ts : Math.min(firstActivity, ts);
@@ -617,9 +625,13 @@ function buildSessionCostSummaryFromCacheEntry(params: {
const latencyMs =
entry.durationMs ??
(lastUserTimestamp !== undefined ? Math.max(0, ts - lastUserTimestamp) : undefined);
if (latencyMs !== undefined && Number.isFinite(latencyMs) && latencyMs <= maxLatencyMs) {
if (
latencyMs !== undefined &&
Number.isFinite(latencyMs) &&
latencyMs <= maxLatencyMs &&
dayKey !== undefined
) {
latencyValues.push(latencyMs);
const dayKey = formatDayKey(new Date(ts), params.dailyUtcOffsetMinutes);
const dailyLatencies = dailyLatencyMap.get(dayKey) ?? [];
dailyLatencies.push(latencyMs);
dailyLatencyMap.set(dayKey, dailyLatencies);
@@ -643,9 +655,7 @@ function buildSessionCostSummaryFromCacheEntry(params: {
messageCounts.errors += 1;
}
if (ts !== undefined) {
const date = new Date(ts);
const dayKey = formatDayKey(date, params.dailyUtcOffsetMinutes);
if (dayKey !== undefined && quarterBucket) {
activityDatesSet.add(dayKey);
const daily = dailyMessageMap.get(dayKey) ?? {
date: dayKey,
@@ -670,7 +680,6 @@ function buildSessionCostSummaryFromCacheEntry(params: {
}
dailyMessageMap.set(dayKey, daily);
const quarterBucket = getUtcQuarterHourBucketKey(date);
const utcQuarterHour = utcQuarterHourMessageMap.get(quarterBucket.key) ?? {
date: quarterBucket.date,
quarterIndex: quarterBucket.quarterIndex,
@@ -702,9 +711,7 @@ function buildSessionCostSummaryFromCacheEntry(params: {
}
addTotals(totals, usageTotals);
if (ts !== undefined) {
const date = new Date(ts);
const dayKey = formatDayKey(date, params.dailyUtcOffsetMinutes);
if (dayKey !== undefined && quarterBucket) {
const componentTokens =
usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite;
const existingDaily = dailyMap.get(dayKey) ?? { tokens: 0, cost: 0 };
@@ -712,7 +719,6 @@ function buildSessionCostSummaryFromCacheEntry(params: {
existingDaily.cost += usageTotals.totalCost;
dailyMap.set(dayKey, existingDaily);
const quarterBucket = getUtcQuarterHourBucketKey(date);
const utcQuarterHourToken = utcQuarterHourTokenMap.get(quarterBucket.key) ?? {
date: quarterBucket.date,
quarterIndex: quarterBucket.quarterIndex,
@@ -929,13 +935,18 @@ const parseTranscriptEntry = (entry: Record<string, unknown>): ParsedTranscriptE
const formatUtcDayKey = (date: Date): string =>
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
const formatDayKey = (date: Date, utcOffsetMinutes?: number): string => {
if (utcOffsetMinutes === undefined) {
return date.toLocaleDateString("en-CA", {
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
type UsageDayKeyFormatter = (date: Date) => string;
const createUsageDayKeyFormatter = (dayBucket?: UsageDailyBucket): UsageDayKeyFormatter => {
if (dayBucket?.mode === "utc-offset") {
return (date) =>
formatUtcDayKey(new Date(date.getTime() + dayBucket.utcOffsetMinutes * 60 * 1000));
}
return formatUtcDayKey(new Date(date.getTime() + utcOffsetMinutes * 60 * 1000));
const timeZone =
dayBucket?.mode === "time-zone"
? dayBucket.timeZone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
return createTimeZoneDayKeyFormatter(timeZone);
};
/**
@@ -978,7 +989,7 @@ const parseDayKeyToUtcMs = (dayKey: string): number | null => {
* resulting `daily` series matches the requested range length (one bar per
* calendar day) instead of only covering days with recorded usage.
*
* Day keys must use the same fixed offset as the request range. Otherwise a
* Day keys must use the same calendar zone as the request range. Otherwise a
* remote Gateway can return local-date labels for UTC/browser-local ranges,
* which drops boundary usage when the UI compares calendar windows.
*/
@@ -986,21 +997,14 @@ const fillMissingDays = (
dailyMap: Map<string, CostUsageTotals>,
startMs: number,
endMs: number,
utcOffsetMinutes?: number,
formatDayKey: UsageDayKeyFormatter,
): void => {
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
return;
}
const dayMs = 24 * 60 * 60 * 1000;
// Bound the fill so unbounded / all-time ranges don't generate tens of
// thousands of zero buckets. Wider ranges keep their existing sparse
// (activity-only) shape.
const spanDays = Math.floor((endMs - startMs) / dayMs) + 1;
if (spanDays > MAX_ZERO_FILL_DAYS) {
return;
}
const startKey = formatDayKey(new Date(startMs), utcOffsetMinutes);
const endKey = formatDayKey(new Date(endMs), utcOffsetMinutes);
const startKey = formatDayKey(new Date(startMs));
const endKey = formatDayKey(new Date(endMs));
const startDayMs = parseDayKeyToUtcMs(startKey);
const endDayMs = parseDayKeyToUtcMs(endKey);
if (startDayMs === null || endDayMs === null) {
@@ -1015,6 +1019,12 @@ const fillMissingDays = (
}
return;
}
// Bound the fill by calendar labels, not elapsed milliseconds: DST days can
// contain 23 or 25 hours. Wider ranges keep their sparse activity-only shape.
const spanDays = Math.floor((endDayMs - startDayMs) / dayMs) + 1;
if (spanDays > MAX_ZERO_FILL_DAYS) {
return;
}
const maxIterations = MAX_ZERO_FILL_DAYS + 1;
for (let cursorMs = startDayMs, i = 0; cursorMs <= endDayMs && i < maxIterations; i += 1) {
const key = formatUtcDayKey(new Date(cursorMs));
@@ -1028,6 +1038,19 @@ const fillMissingDays = (
}
};
const countCalendarDays = (
startMs: number,
endMs: number,
formatDayKey: UsageDayKeyFormatter,
): number => {
const startDayMs = parseDayKeyToUtcMs(formatDayKey(new Date(startMs)));
const endDayMs = parseDayKeyToUtcMs(formatDayKey(new Date(endMs)));
if (startDayMs === null || endDayMs === null || endDayMs < startDayMs) {
return Math.ceil((endMs - startMs) / (24 * 60 * 60 * 1000)) + 1;
}
return Math.floor((endDayMs - startDayMs) / (24 * 60 * 60 * 1000)) + 1;
};
const getUtcQuarterHourBucketKey = (
date: Date,
): { date: string; quarterIndex: number; key: string } => {
@@ -1416,7 +1439,7 @@ export function resolveExistingUsageSessionFile(params: {
export async function loadCostUsageSummary(params?: {
startMs?: number;
endMs?: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
/** @deprecated Use startMs/endMs. */
days?: number;
config?: OpenClawConfig;
@@ -1439,6 +1462,7 @@ export async function loadCostUsageSummary(params?: {
}
const dailyMap = new Map<string, CostUsageTotals>();
const formatDayKey = createUsageDayKeyFormatter(params?.dayBucket);
const totals = emptyTotals();
const resolveCost = createUsageCostResolver(params?.config);
@@ -1456,7 +1480,7 @@ export async function loadCostUsageSummary(params?: {
if (!ts || ts < sinceTime || ts > untilTime) {
return;
}
const dayKey = formatDayKey(entry.timestamp ?? now, params?.dailyUtcOffsetMinutes);
const dayKey = formatDayKey(entry.timestamp ?? now);
const bucket = dailyMap.get(dayKey) ?? emptyTotals();
applyUsageTotals(bucket, entry.usage);
if (entry.costBreakdown?.total !== undefined) {
@@ -1476,14 +1500,14 @@ export async function loadCostUsageSummary(params?: {
});
}
fillMissingDays(dailyMap, sinceTime, untilTime, params?.dailyUtcOffsetMinutes);
fillMissingDays(dailyMap, sinceTime, untilTime, formatDayKey);
const daily = Array.from(dailyMap.entries())
.map(([date, bucket]) => Object.assign({ date }, bucket))
.toSorted((a, b) => a.date.localeCompare(b.date));
// Calculate days for backwards compatibility in response
const days = Math.ceil((untilTime - sinceTime) / (24 * 60 * 60 * 1000)) + 1;
const days = countCalendarDays(sinceTime, untilTime, formatDayKey);
return {
updatedAt: Date.now(),
@@ -1598,6 +1622,7 @@ async function scanUsageFileForCache(params: {
sessionFile: params.file.filePath,
startMs: Number.NEGATIVE_INFINITY,
endMs: Number.POSITIVE_INFINITY,
formatDayKey: createUsageDayKeyFormatter(),
}) ?? undefined)
: undefined;
@@ -1741,7 +1766,7 @@ export async function refreshCostUsageCache(params?: {
export async function loadCostUsageSummaryFromCache(params: {
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
config?: OpenClawConfig;
agentId?: string;
requestRefresh?: boolean;
@@ -1791,7 +1816,7 @@ export async function loadCostUsageSummaryFromCache(params: {
files,
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
dayBucket: params.dayBucket,
refreshing: usageCostRefreshes.has(cachePath) || refreshRunning,
});
}
@@ -1804,7 +1829,7 @@ export async function loadSessionCostSummaryFromCache(params: {
agentId?: string;
startMs?: number;
endMs?: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
requestRefresh?: boolean;
refreshMode?: "background" | "sync-when-empty";
}): Promise<{ summary: SessionCostSummary | null; cacheStatus: UsageCacheStatus }> {
@@ -1869,9 +1894,9 @@ export async function loadSessionCostSummaryFromCache(params: {
const refreshRunning =
usageCostRefreshes.has(cachePath) || (await isUsageCostCacheRefreshRunning(cachePath));
let summary = stale ? null : (entry?.sessionSummary ?? null);
// Persisted summaries use Gateway-local day keys. Request-scoped UTC/browser
// offsets must rebuild daily projections from the cached transcript entries.
const requiresDailyRebucket = params.dailyUtcOffsetMinutes !== undefined;
// Persisted summaries use Gateway-local day keys. Explicit request calendars
// must rebuild daily projections from the cached transcript entries.
const requiresDailyRebucket = params.dayBucket !== undefined;
if (
summary &&
params.startMs !== undefined &&
@@ -1886,7 +1911,7 @@ export async function loadSessionCostSummaryFromCache(params: {
sessionFile: params.sessionFile,
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
formatDayKey: createUsageDayKeyFormatter(params.dayBucket),
})
: null;
}
@@ -1899,7 +1924,7 @@ export async function loadSessionCostSummaryFromCache(params: {
agentId: params.agentId,
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
dayBucket: params.dayBucket,
});
}
return {
@@ -1926,7 +1951,7 @@ export async function loadSessionCostSummariesFromCache(params: {
agentId?: string;
startMs?: number;
endMs?: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
requestRefresh?: boolean;
}): Promise<{ summaries: Array<SessionCostSummary | null>; cacheStatus: UsageCacheStatus }> {
const cachePath = resolveUsageCostCachePath(params.agentId);
@@ -1945,7 +1970,12 @@ export async function loadSessionCostSummariesFromCache(params: {
]);
const staleFiles = new Set<string>();
let cachedFiles = 0;
const requiresDailyRebucket = params.dailyUtcOffsetMinutes !== undefined;
const requiresDailyRebucket = params.dayBucket !== undefined;
let sharedFormatDayKey: UsageDayKeyFormatter | undefined;
// IANA formatter construction is expensive; lazily share it across every
// session rebuilt from this cache snapshot.
const getFormatDayKey = () =>
(sharedFormatDayKey ??= createUsageDayKeyFormatter(params.dayBucket));
const summaries = params.sessions.map((session, index) => {
const stat = stats[index];
const file = stat
@@ -1979,7 +2009,7 @@ export async function loadSessionCostSummariesFromCache(params: {
sessionFile: session.sessionFile,
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
formatDayKey: getFormatDayKey(),
})
: null;
}
@@ -2224,7 +2254,7 @@ export async function loadSessionCostSummary(params: {
agentId?: string;
startMs?: number;
endMs?: number;
dailyUtcOffsetMinutes?: number;
dayBucket?: UsageDailyBucket;
}): Promise<SessionCostSummary | null> {
const sessionFile = resolveExistingUsageSessionFile(params);
if (!sessionFile || !fs.existsSync(sessionFile)) {
@@ -2241,6 +2271,7 @@ export async function loadSessionCostSummary(params: {
const utcQuarterHourTokenMap = new Map<string, SessionUtcQuarterHourTokenUsage>();
const dailyLatencyMap = new Map<string, number[]>();
const dailyModelUsageMap = new Map<string, SessionDailyModelUsage>();
const formatDayKey = createUsageDayKeyFormatter(params.dayBucket);
const messageCounts: SessionMessageCounts = {
total: 0,
user: 0,
@@ -2262,7 +2293,8 @@ export async function loadSessionCostSummary(params: {
config: params.config,
resolveCost,
onEntry: (entry) => {
const ts = entry.timestamp?.getTime();
const timestamp = entry.timestamp;
const ts = timestamp?.getTime();
// Filter by date range if specified
if (params.startMs !== undefined && ts !== undefined && ts < params.startMs) {
@@ -2271,6 +2303,8 @@ export async function loadSessionCostSummary(params: {
if (params.endMs !== undefined && ts !== undefined && ts > params.endMs) {
return;
}
const dayKey = timestamp ? formatDayKey(timestamp) : undefined;
const quarterBucket = timestamp ? getUtcQuarterHourBucketKey(timestamp) : undefined;
if (ts !== undefined) {
if (!firstActivity || ts < firstActivity) {
@@ -2284,30 +2318,24 @@ export async function loadSessionCostSummary(params: {
if (entry.role === "user") {
messageCounts.user += 1;
messageCounts.total += 1;
if (entry.timestamp) {
lastUserTimestamp = entry.timestamp.getTime();
if (ts !== undefined) {
lastUserTimestamp = ts;
}
}
if (entry.role === "assistant") {
messageCounts.assistant += 1;
messageCounts.total += 1;
const tsLocal = entry.timestamp?.getTime();
if (tsLocal !== undefined) {
if (ts !== undefined) {
const latencyMs =
entry.durationMs ??
(lastUserTimestamp !== undefined
? Math.max(0, tsLocal - lastUserTimestamp)
: undefined);
(lastUserTimestamp !== undefined ? Math.max(0, ts - lastUserTimestamp) : undefined);
if (
latencyMs !== undefined &&
Number.isFinite(latencyMs) &&
latencyMs <= MAX_LATENCY_MS
latencyMs <= MAX_LATENCY_MS &&
dayKey !== undefined
) {
latencyValues.push(latencyMs);
const dayKey = formatDayKey(
entry.timestamp ?? new Date(tsLocal),
params.dailyUtcOffsetMinutes,
);
const dailyLatencies = dailyLatencyMap.get(dayKey) ?? [];
dailyLatencies.push(latencyMs);
dailyLatencyMap.set(dayKey, dailyLatencies);
@@ -2331,8 +2359,7 @@ export async function loadSessionCostSummary(params: {
messageCounts.errors += 1;
}
if (entry.timestamp) {
const dayKey = formatDayKey(entry.timestamp, params.dailyUtcOffsetMinutes);
if (dayKey !== undefined && quarterBucket) {
activityDatesSet.add(dayKey);
const daily = dailyMessageMap.get(dayKey) ?? {
date: dayKey,
@@ -2347,7 +2374,6 @@ export async function loadSessionCostSummary(params: {
dailyMessageMap.set(dayKey, daily);
// Per-quarter-hour message counts for precise hourly stats (UTC-based)
const quarterBucket = getUtcQuarterHourBucketKey(entry.timestamp);
const utcQuarterHour = utcQuarterHourMessageMap.get(quarterBucket.key) ?? {
date: quarterBucket.date,
quarterIndex: quarterBucket.quarterIndex,
@@ -2373,8 +2399,7 @@ export async function loadSessionCostSummary(params: {
applyCostTotal(totals, entry.costTotal);
}
if (entry.timestamp) {
const dayKey = formatDayKey(entry.timestamp, params.dailyUtcOffsetMinutes);
if (dayKey !== undefined && quarterBucket) {
const entryTokenTotals = computeUsageTokenTotals(entry.usage);
// Preserve the legacy dailyBreakdown token basis until daily metrics are
// refactored separately. The precise quarter-hour bucket below uses
@@ -2389,7 +2414,6 @@ 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,

View File

@@ -67,6 +67,10 @@ export type CostUsageSummary = {
export type UsageCacheStatus = NonNullable<CostUsageSummary["cacheStatus"]>;
export type UsageDailyBucket =
| { mode: "utc-offset"; utcOffsetMinutes: number }
| { mode: "time-zone"; timeZone: string };
export type SessionDailyUsage = {
date: string; // YYYY-MM-DD
tokens: number;

View File

@@ -38,6 +38,7 @@ export {
requestSessionUsage,
requestSessionUsageLogs,
requestSessionUsageTimeSeries,
requestSessionsUsage,
} from "./usage.ts";
export type { SessionUsageQuery } from "./usage.ts";

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GatewayRequestError } from "../../api/gateway.ts";
import { buildSessionUsageDateParams, requestSessionsUsage } from "./usage.ts";
describe("buildSessionUsageDateParams", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses UTC mode without local timezone parameters", () => {
expect(buildSessionUsageDateParams("utc")).toEqual({ mode: "utc" });
});
it("sends the browser IANA timezone with the current UTC offset in local mode", () => {
const resolvedOptions = new Intl.DateTimeFormat().resolvedOptions();
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
...resolvedOptions,
timeZone: "Europe/Vienna",
});
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-120);
expect(buildSessionUsageDateParams("local")).toEqual({
mode: "specific",
timeZone: "Europe/Vienna",
utcOffset: "UTC+2",
});
});
});
describe("requestSessionsUsage", () => {
it("retries older gateways with the legacy UTC offset", async () => {
const result = { sessions: [] };
const request = vi
.fn()
.mockRejectedValueOnce(
new GatewayRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.usage params: at root: unexpected property 'timeZone'",
}),
)
.mockResolvedValueOnce(result);
const params = {
range: "all",
mode: "specific",
timeZone: "Europe/Vienna",
utcOffset: "UTC+2",
};
await expect(requestSessionsUsage({ request } as never, params)).resolves.toBe(result);
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", params);
expect(request).toHaveBeenNthCalledWith(2, "sessions.usage", {
range: "all",
mode: "specific",
utcOffset: "UTC+2",
});
});
it("does not retry unrelated invalid usage parameters", async () => {
const error = new GatewayRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.usage params: invalid IANA timeZone",
});
const request = vi.fn().mockRejectedValue(error);
await expect(
requestSessionsUsage({ request } as never, {
mode: "specific",
timeZone: "Not/AZone",
utcOffset: "UTC+2",
}),
).rejects.toBe(error);
expect(request).toHaveBeenCalledOnce();
});
});

View File

@@ -1,6 +1,6 @@
import type { SessionUsageTimeSeries } from "../../../../src/shared/session-usage-timeseries-types.js";
import type { SessionsUsageResult } from "../../../../src/shared/usage-types.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
type SessionRequestClient = Pick<GatewayBrowserClient, "request">;
@@ -28,6 +28,7 @@ export function buildSessionUsageDateParams(timeZone: "local" | "utc") {
? { mode: "utc" }
: {
mode: "specific",
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
utcOffset: formatUtcOffset(new Date().getTimezoneOffset()),
};
}
@@ -45,11 +46,43 @@ function buildSessionUsageParams(query: SessionUsageQuery): Record<string, unkno
};
}
function isOlderGatewayWithoutUsageTimeZone(
error: unknown,
params: Record<string, unknown>,
): boolean {
return (
typeof params.timeZone === "string" &&
typeof params.utcOffset === "string" &&
error instanceof GatewayRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("invalid sessions.usage params:") &&
error.message.includes("unexpected property 'timeZone'")
);
}
export async function requestSessionsUsage(
client: SessionRequestClient,
params: Record<string, unknown>,
): Promise<SessionsUsageResult> {
try {
return await client.request<SessionsUsageResult>("sessions.usage", params);
} catch (error) {
if (!isOlderGatewayWithoutUsageTimeZone(error, params)) {
throw error;
}
// Protocol v4 gateways predating timeZone reject the additive field.
// Retry with the accompanying fixed offset for mixed-version clients.
const legacyParams = { ...params };
delete legacyParams.timeZone;
return await client.request<SessionsUsageResult>("sessions.usage", legacyParams);
}
}
export function requestSessionUsage(
client: SessionRequestClient,
query: SessionUsageQuery,
): Promise<SessionsUsageResult> {
return client.request<SessionsUsageResult>("sessions.usage", buildSessionUsageParams(query));
return requestSessionsUsage(client, buildSessionUsageParams(query));
}
export function requestSessionUsageTimeSeries(

View File

@@ -18,7 +18,7 @@ import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { buildSessionUsageDateParams } from "../../lib/sessions/index.ts";
import { buildSessionUsageDateParams, requestSessionsUsage } from "../../lib/sessions/index.ts";
import {
buildHeatmap,
buildInsights,
@@ -165,10 +165,6 @@ class ProfilePage extends LitElement {
const requestId = ++this.requestId;
this.loading = true;
this.error = null;
// Day buckets use the browser's current fixed UTC offset — the same "local"
// semantics as the Usage page. Midnight-adjacent history from the opposite
// DST season can land on a neighboring day; DST-aware bucketing needs an
// IANA-timezone protocol parameter (tracked as a usage-wide follow-up).
const dateParams = buildSessionUsageDateParams("local");
try {
const [costSummary, sessionsResult] = await Promise.all([
@@ -178,17 +174,15 @@ class ProfilePage extends LitElement {
agentScope: "all",
...dateParams,
}),
client
.request<SessionsUsageResult>("sessions.usage", {
range: "all",
agentScope: "all",
// Instance rows keep durations per transcript; family rollups would
// merge resets and inflate "Longest session" to the family lifespan.
groupBy: "instance",
limit: 1000,
...dateParams,
})
.catch(() => null),
requestSessionsUsage(client, {
range: "all",
agentScope: "all",
// Instance rows keep durations per transcript; family rollups would
// merge resets and inflate "Longest session" to the family lifespan.
groupBy: "instance",
limit: 1000,
...dateParams,
}).catch(() => null),
]);
if (requestId !== this.requestId) {
return;