Skip to content

Commit 37f96bd

Browse files
fix(gateway): format usage dates in range timezone (#100567)
* fix: format usage date labels by range timezone * fix(gateway): keep usage ranges on calendar days --------- Co-authored-by: NianJiuZst <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent b7e5465 commit 37f96bd

4 files changed

Lines changed: 154 additions & 55 deletions

File tree

src/gateway/server-methods/usage.cost-usage-cache.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Regression: costUsageCache (usage.ts:65) has no production delete/prune/evict
22
// path. The TTL at L310 is read-only — on a miss after expiry, set() overwrites
3-
// the same key but never removes stale keys. parseDateRange derives cacheKey
4-
// from getTodayStartMs so cacheKey rolls at every UTC 00:00, and additional
3+
// the same key but never removes stale keys. resolveDateRange derives cacheKey
4+
// from the current calendar day so cacheKey rolls at every UTC 00:00, and additional
55
// axes (days, startDate, endDate, utcOffset) multiply cardinality.
66
//
77
// The same file has three sibling caches that implement MAX + FIFO eviction

src/gateway/server-methods/usage.sessions-usage.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,39 @@ describe("sessions.usage", () => {
344344
);
345345
});
346346

347+
it("formats response date labels in the requested timezone offset", async () => {
348+
const respond = await runSessionsUsage({
349+
...BASE_USAGE_RANGE,
350+
startDate: "2026-07-06",
351+
endDate: "2026-07-06",
352+
mode: "specific",
353+
utcOffset: "UTC+8",
354+
});
355+
356+
expect(respond).toHaveBeenCalledTimes(1);
357+
expect(mockArg(respond, 0, 0)).toBe(true);
358+
const result = mockArg(respond, 0, 1) as { startDate: string; endDate: string };
359+
expect(result.startDate).toBe("2026-07-06");
360+
expect(result.endDate).toBe("2026-07-06");
361+
});
362+
363+
it("keeps explicit gateway response date labels on DST-short days", async () => {
364+
await withEnvAsync({ TZ: "America/New_York" }, async () => {
365+
const respond = await runSessionsUsage({
366+
...BASE_USAGE_RANGE,
367+
startDate: "2026-03-08",
368+
endDate: "2026-03-08",
369+
mode: "gateway",
370+
});
371+
372+
expect(respond).toHaveBeenCalledTimes(1);
373+
expect(mockArg(respond, 0, 0)).toBe(true);
374+
const result = mockArg(respond, 0, 1) as { startDate: string; endDate: string };
375+
expect(result.startDate).toBe("2026-03-08");
376+
expect(result.endDate).toBe("2026-03-08");
377+
});
378+
});
379+
347380
it("discovers usage for requested disk-only agents not listed in config", async () => {
348381
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, agentId: "codex" });
349382

src/gateway/server-methods/usage.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ describe("gateway usage helpers", () => {
8989
return result.value;
9090
}
9191

92+
function withTimeZone<T>(timeZone: string, run: () => T): T {
93+
const previous = process.env.TZ;
94+
process.env.TZ = timeZone;
95+
try {
96+
return run();
97+
} finally {
98+
if (previous === undefined) {
99+
delete process.env.TZ;
100+
} else {
101+
process.env.TZ = previous;
102+
}
103+
}
104+
}
105+
92106
beforeEach(() => {
93107
testApi.costUsageCache.clear();
94108
vi.useRealTimers();
@@ -262,6 +276,35 @@ describe("gateway usage helpers", () => {
262276
expect(range.endMs).toBe(expectedStart + dayMs - 1);
263277
});
264278

279+
it("resolveDateRange uses gateway calendar end boundaries for explicit DST-short days", () => {
280+
withTimeZone("America/New_York", () => {
281+
const range = expectDateRange(
282+
testApi.resolveDateRange({
283+
startDate: "2026-03-08",
284+
endDate: "2026-03-08",
285+
mode: "gateway",
286+
}),
287+
);
288+
expect(range.startMs).toBe(new Date(2026, 2, 8).getTime());
289+
expect(range.endMs).toBe(new Date(2026, 2, 9).getTime() - 1);
290+
});
291+
});
292+
293+
it("resolveDateRange keeps trailing gateway ranges on calendar days across DST", () => {
294+
withTimeZone("America/New_York", () => {
295+
vi.useFakeTimers();
296+
vi.setSystemTime(new Date("2026-03-09T12:00:00.000Z"));
297+
const range = expectDateRange(
298+
testApi.resolveDateRange({
299+
days: 2,
300+
mode: "gateway",
301+
}),
302+
);
303+
expect(range.startMs).toBe(new Date(2026, 2, 8).getTime());
304+
expect(range.endMs).toBe(new Date(2026, 2, 10).getTime() - 1);
305+
});
306+
});
307+
265308
it("resolveDateRange clamps days to at least 1 and defaults to 30 days", () => {
266309
vi.useFakeTimers();
267310
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));

src/gateway/server-methods/usage.ts

Lines changed: 76 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ import type { GatewayRequestHandlers, RespondFn } from "./types.js";
6565
const COST_USAGE_CACHE_TTL_MS = 30_000;
6666
const COST_USAGE_CACHE_MAX = 256;
6767
const SESSIONS_USAGE_AGENT_LOAD_CONCURRENCY = 12;
68-
const DAY_MS = 24 * 60 * 60 * 1000;
6968

7069
type DateRange = { startMs: number; endMs: number };
7170
// Keep validation and parsed timestamps in one result so handlers cannot forward
@@ -74,6 +73,7 @@ type DateRangeResolution = { ok: true; value: DateRange } | { ok: false; error:
7473
type DateInterpretation =
7574
| { mode: "utc" | "gateway" }
7675
| { mode: "specific"; utcOffsetMinutes: number };
76+
type DateParts = { year: number; monthIndex: number; day: number };
7777

7878
type CostUsageCacheEntry = {
7979
summary?: CostUsageSummary;
@@ -139,9 +139,7 @@ function resolveSessionUsageFileOrRespond(
139139
return { config, entry, agentId, sessionId, sessionFile };
140140
}
141141

142-
const parseDateParts = (
143-
raw: unknown,
144-
): { year: number; monthIndex: number; day: number } | undefined => {
142+
const parseDateParts = (raw: unknown): DateParts | undefined => {
145143
if (typeof raw !== "string" || !raw.trim()) {
146144
return undefined;
147145
}
@@ -170,6 +168,29 @@ const parseDateParts = (
170168
return { year, monthIndex, day };
171169
};
172170

171+
const shiftDateParts = (parts: DateParts, days: number): DateParts => {
172+
const shifted = new Date(Date.UTC(parts.year, parts.monthIndex, parts.day + days));
173+
return {
174+
year: shifted.getUTCFullYear(),
175+
monthIndex: shifted.getUTCMonth(),
176+
day: shifted.getUTCDate(),
177+
};
178+
};
179+
180+
const datePartsToStartMs = (parts: DateParts, interpretation: DateInterpretation): number => {
181+
const { year, monthIndex, day } = parts;
182+
if (interpretation.mode === "gateway") {
183+
return new Date(year, monthIndex, day).getTime();
184+
}
185+
if (interpretation.mode === "specific") {
186+
return Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
187+
}
188+
return Date.UTC(year, monthIndex, day);
189+
};
190+
191+
const datePartsToEndMs = (parts: DateParts, interpretation: DateInterpretation): number =>
192+
datePartsToStartMs(shiftDateParts(parts, 1), interpretation) - 1;
193+
173194
// usage.cost / sessions.usage accept optional startDate/endDate. parseDateParts returns
174195
// undefined for both absent and invalid input, so an explicitly supplied but unparseable
175196
// date (bad format or impossible calendar date like 2026-02-30) would otherwise silently
@@ -243,6 +264,25 @@ const resolveDayBucketUtcOffsetMinutes = (interpretation: DateInterpretation) =>
243264
? interpretation.utcOffsetMinutes
244265
: 0;
245266

267+
const getDateParts = (date: Date, interpretation: DateInterpretation): DateParts => {
268+
if (interpretation.mode === "gateway") {
269+
return { year: date.getFullYear(), monthIndex: date.getMonth(), day: date.getDate() };
270+
}
271+
if (interpretation.mode === "specific") {
272+
const shifted = new Date(date.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
273+
return {
274+
year: shifted.getUTCFullYear(),
275+
monthIndex: shifted.getUTCMonth(),
276+
day: shifted.getUTCDate(),
277+
};
278+
}
279+
return {
280+
year: date.getUTCFullYear(),
281+
monthIndex: date.getUTCMonth(),
282+
day: date.getUTCDate(),
283+
};
284+
};
285+
246286
/**
247287
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
248288
* Returns undefined if invalid.
@@ -255,33 +295,17 @@ const parseDateToMs = (
255295
if (!parts) {
256296
return undefined;
257297
}
258-
const { year, monthIndex, day } = parts;
259-
if (interpretation.mode === "gateway") {
260-
const ms = new Date(year, monthIndex, day).getTime();
261-
return Number.isNaN(ms) ? undefined : ms;
262-
}
263-
if (interpretation.mode === "specific") {
264-
const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
265-
return Number.isNaN(ms) ? undefined : ms;
266-
}
267-
const ms = Date.UTC(year, monthIndex, day);
268-
return Number.isNaN(ms) ? undefined : ms;
298+
return datePartsToStartMs(parts, interpretation);
269299
};
270300

271-
const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => {
272-
if (interpretation.mode === "gateway") {
273-
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
274-
}
275-
if (interpretation.mode === "specific") {
276-
const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
277-
return (
278-
Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) -
279-
interpretation.utcOffsetMinutes * 60 * 1000
280-
);
281-
}
282-
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
301+
const formatDateLabel = (ms: number, interpretation: DateInterpretation): string => {
302+
const parts = getDateParts(new Date(ms), interpretation);
303+
return formatDateParts(parts.year, parts.monthIndex, parts.day);
283304
};
284305

306+
const formatDateParts = (year: number, monthIndex: number, day: number): string =>
307+
`${year}-${String(monthIndex + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
308+
285309
const parseDays = (raw: unknown): number | undefined => {
286310
if (typeof raw === "number" && Number.isFinite(raw)) {
287311
return Math.floor(raw);
@@ -314,6 +338,15 @@ const resolveRangeDays = (raw: unknown): number | "all" | undefined => {
314338
return undefined;
315339
};
316340

341+
const resolveTrailingDays = (
342+
endDateParts: DateParts,
343+
days: number,
344+
interpretation: DateInterpretation,
345+
): DateRange => ({
346+
startMs: datePartsToStartMs(shiftDateParts(endDateParts, -(days - 1)), interpretation),
347+
endMs: datePartsToEndMs(endDateParts, interpretation),
348+
});
349+
317350
/**
318351
* Get date range from params (startDate/endDate or days).
319352
* Falls back to last 30 days if not provided.
@@ -336,39 +369,37 @@ const resolveDateRange = (params: {
336369

337370
const now = new Date();
338371
const interpretation = resolveDateInterpretation(params);
339-
const todayStartMs = getTodayStartMs(now, interpretation);
340-
const todayEndMs = todayStartMs + DAY_MS - 1;
372+
const todayDateParts = getDateParts(now, interpretation);
373+
const todayEndMs = datePartsToEndMs(todayDateParts, interpretation);
341374

342-
const startMs = parseDateToMs(params.startDate, interpretation);
343-
const endMs = parseDateToMs(params.endDate, interpretation);
375+
const startDateParts = parseDateParts(params.startDate);
376+
const endDateParts = parseDateParts(params.endDate);
344377

345-
if (startMs !== undefined && endMs !== undefined) {
346-
if (startMs > endMs) {
378+
if (startDateParts && endDateParts) {
379+
const startMs = datePartsToStartMs(startDateParts, interpretation);
380+
const endStartMs = datePartsToStartMs(endDateParts, interpretation);
381+
if (startMs > endStartMs) {
347382
return { ok: false, error: "startDate must not be after endDate" };
348383
}
349-
// endMs should be end of day
350-
return { ok: true, value: { startMs, endMs: endMs + DAY_MS - 1 } };
384+
return { ok: true, value: { startMs, endMs: datePartsToEndMs(endDateParts, interpretation) } };
351385
}
352386

353387
const rangeDays = resolveRangeDays(params.range);
354388
if (rangeDays === "all") {
355389
return { ok: true, value: { startMs: 0, endMs: todayEndMs } };
356390
}
357391
if (rangeDays !== undefined) {
358-
const start = todayStartMs - (rangeDays - 1) * DAY_MS;
359-
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
392+
return { ok: true, value: resolveTrailingDays(todayDateParts, rangeDays, interpretation) };
360393
}
361394

362395
const days = parseDays(params.days);
363396
if (days !== undefined) {
364397
const clampedDays = Math.max(1, days);
365-
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
366-
return { ok: true, value: { startMs: start, endMs: todayEndMs } };
398+
return { ok: true, value: resolveTrailingDays(todayDateParts, clampedDays, interpretation) };
367399
}
368400

369401
// Default to last 30 days
370-
const defaultStartMs = todayStartMs - 29 * DAY_MS;
371-
return { ok: true, value: { startMs: defaultStartMs, endMs: todayEndMs } };
402+
return { ok: true, value: resolveTrailingDays(todayDateParts, 30, interpretation) };
372403
};
373404

374405
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
@@ -904,7 +935,6 @@ export const testApi = {
904935
parseUtcOffsetToMinutes,
905936
resolveDateInterpretation,
906937
parseDateToMs,
907-
getTodayStartMs,
908938
parseDays,
909939
resolveDateRange,
910940
discoverAllSessionsForUsage,
@@ -978,9 +1008,8 @@ export const usageHandlers: GatewayRequestHandlers = {
9781008
}
9791009
const config = context.getRuntimeConfig();
9801010
const { startMs, endMs } = dateRange.value;
981-
const dailyUtcOffsetMinutes = resolveDayBucketUtcOffsetMinutes(
982-
resolveDateInterpretation({ mode: p.mode, utcOffset: p.utcOffset }),
983-
);
1011+
const dateInterpretation = resolveDateInterpretation({ mode: p.mode, utcOffset: p.utcOffset });
1012+
const dailyUtcOffsetMinutes = resolveDayBucketUtcOffsetMinutes(dateInterpretation);
9841013
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
9851014
const includeContextWeight = p.includeContextWeight ?? false;
9861015
const specificKey = normalizeOptionalString(p.key) ?? null;
@@ -1426,12 +1455,6 @@ export const usageHandlers: GatewayRequestHandlers = {
14261455
}
14271456
}
14281457

1429-
// Format dates back to YYYY-MM-DD strings
1430-
const formatDateStr = (ms: number) => {
1431-
const d = new Date(ms);
1432-
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
1433-
};
1434-
14351458
const tail = buildUsageAggregateTail({
14361459
byChannelMap,
14371460
latencyTotals,
@@ -1471,8 +1494,8 @@ export const usageHandlers: GatewayRequestHandlers = {
14711494

14721495
const result: SessionsUsageResult = {
14731496
updatedAt: now,
1474-
startDate: formatDateStr(startMs),
1475-
endDate: formatDateStr(endMs),
1497+
startDate: formatDateLabel(startMs, dateInterpretation),
1498+
endDate: formatDateLabel(endMs, dateInterpretation),
14761499
sessions,
14771500
totals: aggregateTotals,
14781501
aggregates,

0 commit comments

Comments
 (0)