mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 04:31:39 +00:00
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:
committed by
GitHub
parent
90b68536c4
commit
db44b284ff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
requestSessionUsage,
|
||||
requestSessionUsageLogs,
|
||||
requestSessionUsageTimeSeries,
|
||||
requestSessionsUsage,
|
||||
} from "./usage.ts";
|
||||
export type { SessionUsageQuery } from "./usage.ts";
|
||||
|
||||
|
||||
74
ui/src/lib/sessions/usage.test.ts
Normal file
74
ui/src/lib/sessions/usage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user