Files
openclaw/ui/src/lib/sessions/usage.ts
Peter Steinberger db44b284ff 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
2026-07-09 22:39:57 +01:00

106 lines
3.4 KiB
TypeScript

import type { SessionUsageTimeSeries } from "../../../../src/shared/session-usage-timeseries-types.js";
import type { SessionsUsageResult } from "../../../../src/shared/usage-types.js";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
type SessionRequestClient = Pick<GatewayBrowserClient, "request">;
export type SessionUsageQuery = {
startDate: string;
endDate: string;
scope: "instance" | "family";
timeZone: "local" | "utc";
agentId?: string;
};
function formatUtcOffset(timezoneOffsetMinutes: number): string {
const offsetFromUtcMinutes = -timezoneOffsetMinutes;
const sign = offsetFromUtcMinutes >= 0 ? "+" : "-";
const absMinutes = Math.abs(offsetFromUtcMinutes);
const hours = Math.floor(absMinutes / 60);
const minutes = absMinutes % 60;
return minutes === 0
? `UTC${sign}${hours}`
: `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
}
export function buildSessionUsageDateParams(timeZone: "local" | "utc") {
return timeZone === "utc"
? { mode: "utc" }
: {
mode: "specific",
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
utcOffset: formatUtcOffset(new Date().getTimezoneOffset()),
};
}
function buildSessionUsageParams(query: SessionUsageQuery): Record<string, unknown> {
return {
startDate: query.startDate,
endDate: query.endDate,
...(query.agentId ? { agentId: query.agentId } : { agentScope: "all" }),
...buildSessionUsageDateParams(query.timeZone),
groupBy: query.scope,
includeHistorical: query.scope === "family",
limit: 1000,
includeContextWeight: true,
};
}
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 requestSessionsUsage(client, buildSessionUsageParams(query));
}
export function requestSessionUsageTimeSeries(
client: SessionRequestClient,
key: string,
): Promise<SessionUsageTimeSeries | null> {
return client
.request<SessionUsageTimeSeries | undefined>("sessions.usage.timeseries", { key })
.then((result) => result ?? null);
}
export function requestSessionUsageLogs(
client: SessionRequestClient,
key: string,
): Promise<{ logs?: unknown }> {
return client.request<{ logs?: unknown }>("sessions.usage.logs", {
key,
limit: 1000,
});
}