From db44b284ff89fcdbbb3101fe097d512e8d373631 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 22:39:57 +0100 Subject: [PATCH] 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 --- .../OpenClawProtocol/GatewayModels.swift | 4 + docs/gateway/protocol.md | 1 + packages/gateway-protocol/src/index.test.ts | 10 + .../gateway-protocol/src/schema/sessions.ts | 2 + scripts/protocol-gen-swift.ts | 2 +- .../usage.sessions-usage.test.ts | 42 ++- src/gateway/server-methods/usage.test.ts | 124 ++++++++- src/gateway/server-methods/usage.ts | 239 +++++++++++++----- src/infra/format-time/format-datetime.ts | 51 ++++ src/infra/format-time/format-time.test.ts | 26 +- src/infra/session-cost-usage.test.ts | 121 ++++++++- src/infra/session-cost-usage.ts | 158 +++++++----- src/infra/session-cost-usage.types.ts | 4 + ui/src/lib/sessions/index.ts | 1 + ui/src/lib/sessions/usage.test.ts | 74 ++++++ ui/src/lib/sessions/usage.ts | 37 ++- ui/src/pages/profile/profile-page.ts | 26 +- 17 files changed, 760 insertions(+), 162 deletions(-) create mode 100644 ui/src/lib/sessions/usage.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ef46dbd7be84..e1de90feed23 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index d36a32844179..710b17d6e9a9 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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. diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 768d8eb5e175..ff6a3c312914 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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({ diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 8259def971a5..998eda7e6d8f 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -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). */ diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 922dd7821b68..ec5250fbe812 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -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"]], diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 809d38eb14b4..3baac07068be 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -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, diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index e259bb3cca20..9530ed4129e0 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -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" }, + }), ); }); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 03052c36573e..ef91730df4ed 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -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 { - 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 { 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, }), })), ); diff --git a/src/infra/format-time/format-datetime.ts b/src/infra/format-time/format-datetime.ts index 2a25546136f4..63cd9a5b7d83 100644 --- a/src/infra/format-time/format-datetime.ts +++ b/src/infra/format-time/format-datetime.ts @@ -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; diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index a6991a23edb0..f72c4621de00 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -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" }, diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 641bea4c51b9..37bb0838ed83 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -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", }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index eef487c9edec..83b17da82ccb 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -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(); + 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(); const dailyLatencyMap = new Map(); const dailyModelUsageMap = new Map(); + 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): 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, 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(); + 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; cacheStatus: UsageCacheStatus }> { const cachePath = resolveUsageCostCachePath(params.agentId); @@ -1945,7 +1970,12 @@ export async function loadSessionCostSummariesFromCache(params: { ]); const staleFiles = new Set(); 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 { const sessionFile = resolveExistingUsageSessionFile(params); if (!sessionFile || !fs.existsSync(sessionFile)) { @@ -2241,6 +2271,7 @@ export async function loadSessionCostSummary(params: { const utcQuarterHourTokenMap = new Map(); const dailyLatencyMap = new Map(); const dailyModelUsageMap = new Map(); + 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, diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 68c360fa9a43..5071b9f893d6 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -67,6 +67,10 @@ export type CostUsageSummary = { export type UsageCacheStatus = NonNullable; +export type UsageDailyBucket = + | { mode: "utc-offset"; utcOffsetMinutes: number } + | { mode: "time-zone"; timeZone: string }; + export type SessionDailyUsage = { date: string; // YYYY-MM-DD tokens: number; diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 5a8c7df6be0e..2fa086c4f9ec 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -38,6 +38,7 @@ export { requestSessionUsage, requestSessionUsageLogs, requestSessionUsageTimeSeries, + requestSessionsUsage, } from "./usage.ts"; export type { SessionUsageQuery } from "./usage.ts"; diff --git a/ui/src/lib/sessions/usage.test.ts b/ui/src/lib/sessions/usage.test.ts new file mode 100644 index 000000000000..76a23126182c --- /dev/null +++ b/ui/src/lib/sessions/usage.test.ts @@ -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(); + }); +}); diff --git a/ui/src/lib/sessions/usage.ts b/ui/src/lib/sessions/usage.ts index eae33ac552ff..059507355112 100644 --- a/ui/src/lib/sessions/usage.ts +++ b/ui/src/lib/sessions/usage.ts @@ -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; @@ -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, +): 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, +): Promise { + try { + return await client.request("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("sessions.usage", legacyParams); + } +} + export function requestSessionUsage( client: SessionRequestClient, query: SessionUsageQuery, ): Promise { - return client.request("sessions.usage", buildSessionUsageParams(query)); + return requestSessionsUsage(client, buildSessionUsageParams(query)); } export function requestSessionUsageTimeSeries( diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index f024f1e1608f..59b9b8286ccd 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -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("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;