fix(usage): roll up session lineage history

This commit is contained in:
Val Alexander
2026-05-02 09:46:24 -05:00
parent fb106fb9ae
commit 039eee32ec
18 changed files with 631 additions and 37 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev.
- Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev.
- Channels/streaming: make progress draft labels scroll away with other progress lines, render structured tool rows as compact emoji/title/details, show web-search queries from provider-native argument shapes, and skip empty Discord apply-patch starts until a patch summary exists. (#79146)
- Telegram: preserve the channel-specific 10-option poll cap in the unified outbound adapter so over-limit polls are rejected before send. (#78762) Thanks @obviyus.

View File

@@ -62,6 +62,10 @@ export async function resetReplyRunSession(params: {
sessionId: nextSessionId,
updatedAt: now,
sessionStartedAt: now,
usageFamilyKey: prevEntry.usageFamilyKey ?? params.sessionKey,
usageFamilySessionIds: Array.from(
new Set([...(prevEntry.usageFamilySessionIds ?? []), prevEntry.sessionId, nextSessionId]),
),
lastInteractionAt: now,
systemSent: false,
abortedLastRun: false,

View File

@@ -278,6 +278,10 @@ export async function incrementCompactionCount(params: {
storePath,
newSessionId,
});
updates.usageFamilyKey = entry.usageFamilyKey ?? sessionKey;
updates.usageFamilySessionIds = Array.from(
new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, newSessionId]),
);
} else if (sessionFileChanged && explicitNewSessionFile) {
updates.sessionFile = explicitNewSessionFile;
}

View File

@@ -220,6 +220,10 @@ export type SessionEntry = {
quotaSuspension?: QuotaSuspension;
/** Timestamp (ms) when the current sessionId first became active. */
sessionStartedAt?: number;
/** Stable usage lineage key for transcript-backed rollups across sessionId rotations. */
usageFamilyKey?: string;
/** Session ids known to belong to this usage lineage, including archived predecessors. */
usageFamilySessionIds?: string[];
/** Timestamp (ms) of the last user/channel interaction that should extend idle lifetime. */
lastInteractionAt?: number;
/** Stable first-run start time for subagent sessions, persisted after completion. */

View File

@@ -346,6 +346,20 @@ export const SessionsUsageParamsSchema = Type.Object(
mode: Type.Optional(
Type.Union([Type.Literal("utc"), Type.Literal("gateway"), Type.Literal("specific")]),
),
/** Preset range for usage queries when explicit start/end dates are omitted. */
range: Type.Optional(
Type.Union([
Type.Literal("7d"),
Type.Literal("30d"),
Type.Literal("90d"),
Type.Literal("1y"),
Type.Literal("all"),
]),
),
/** Usage row grouping. `family` rolls up known rotated session ids for a logical key. */
groupBy: Type.Optional(Type.Union([Type.Literal("instance"), Type.Literal("family")])),
/** Backward-compatible alias for requesting family grouping. */
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)?$" })),
/** Maximum sessions to return (default 50). */

View File

@@ -214,6 +214,90 @@ describe("sessions.usage", () => {
}
});
it("rolls up known session family ids when historical usage is requested", async () => {
const storeKey = "agent:opus:main";
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
fs.mkdirSync(agentSessionsDir, { recursive: true });
fs.writeFileSync(path.join(agentSessionsDir, "current.jsonl"), "", "utf-8");
fs.writeFileSync(
path.join(agentSessionsDir, "old.jsonl.reset.2026-02-01T00-00-00.000Z"),
"",
"utf-8",
);
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
[storeKey]: {
sessionId: "current",
sessionFile: "current.jsonl",
updatedAt: 1_000,
usageFamilyKey: storeKey,
usageFamilySessionIds: ["old", "current"],
},
},
});
vi.mocked(loadSessionCostSummary).mockImplementation(async ({ sessionId }) => ({
input: sessionId === "old" ? 10 : 20,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: sessionId === "old" ? 10 : 20,
totalCost: sessionId === "old" ? 0.01 : 0.02,
inputCost: sessionId === "old" ? 0.01 : 0.02,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
messageCounts: {
total: 1,
user: 1,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
},
}));
const respond = await runSessionsUsage({
...BASE_USAGE_RANGE,
key: storeKey,
groupBy: "family",
includeHistorical: true,
});
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true);
const result = respond.mock.calls[0]?.[1] as {
sessions: Array<{
key: string;
scope?: string;
includedSessionIds?: string[];
usage?: { totalTokens: number; totalCost: number; messageCounts?: { total: number } };
}>;
totals: { totalTokens: number; totalCost: number };
};
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
key: storeKey,
scope: "family",
includedSessionIds: ["current", "old"],
});
expect(result.sessions[0]?.usage?.totalTokens).toBe(30);
expect(result.sessions[0]?.usage?.totalCost).toBeCloseTo(0.03);
expect(result.sessions[0]?.usage?.messageCounts?.total).toBe(2);
expect(result.totals.totalTokens).toBe(30);
expect(result.totals.totalCost).toBeCloseTo(0.03);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("prefers the deterministic store key when duplicate sessionIds exist", async () => {
const preferredKey = "agent:opus:acp:run-dup";
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));

View File

@@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type {
CostUsageSummary,
SessionCostSummary,
SessionDailyModelUsage,
SessionMessageCounts,
SessionModelUsage,
@@ -237,6 +238,25 @@ const parseDays = (raw: unknown): number | undefined => {
return undefined;
};
const resolveRangeDays = (raw: unknown): number | "all" | undefined => {
if (raw === "all") {
return "all";
}
if (raw === "7d") {
return 7;
}
if (raw === "30d") {
return 30;
}
if (raw === "90d") {
return 90;
}
if (raw === "1y") {
return 365;
}
return undefined;
};
/**
* Get date range from params (startDate/endDate or days).
* Falls back to last 30 days if not provided.
@@ -245,6 +265,7 @@ const parseDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
range?: unknown;
mode?: unknown;
utcOffset?: unknown;
}): DateRange => {
@@ -261,6 +282,15 @@ const parseDateRange = (params: {
return { startMs, endMs: endMs + DAY_MS - 1 };
}
const rangeDays = resolveRangeDays(params.range);
if (rangeDays === "all") {
return { startMs: 0, endMs: todayEndMs };
}
if (rangeDays !== undefined) {
const start = todayStartMs - (rangeDays - 1) * DAY_MS;
return { startMs: start, endMs: todayEndMs };
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
@@ -274,6 +304,21 @@ const parseDateRange = (params: {
};
type DiscoveredSessionWithAgent = DiscoveredSession & { agentId: string };
type UsageGroupingMode = "instance" | "family";
type MergedEntry = {
key: string;
sessionId: string;
sessionFile: string;
label?: string;
updatedAt: number;
storeEntry?: SessionEntry;
firstUserMessage?: string;
scope?: "instance" | "family";
sessionFamilyKey?: string;
currentSessionId?: string;
includedSessionIds?: string[];
};
function buildStoreBySessionId(
store: Record<string, SessionEntry>,
@@ -322,6 +367,323 @@ async function discoverAllSessionsForUsage(params: {
return results.flat().toSorted((a, b) => b.mtime - a.mtime);
}
function addUniqueSessionIds(target: string[], ids: Array<string | undefined>): string[] {
const seen = new Set(target);
for (const id of ids) {
const normalized = normalizeOptionalString(id);
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
target.push(normalized);
}
}
return target;
}
function resolveUsageFamilySessionIds(entry: SessionEntry | undefined, currentSessionId: string) {
return addUniqueSessionIds([], [currentSessionId, ...(entry?.usageFamilySessionIds ?? [])]);
}
function resolveUsageFamilyKey(params: {
key: string;
entry: SessionEntry | undefined;
sessionId: string;
}): string {
return params.entry?.usageFamilyKey ?? params.key ?? params.sessionId;
}
function maybeMergeFamilyEntry(params: {
mergedEntries: MergedEntry[];
base: MergedEntry;
groupingMode: UsageGroupingMode;
}) {
if (params.groupingMode !== "family") {
params.mergedEntries.push(params.base);
return;
}
const includedSessionIds = resolveUsageFamilySessionIds(
params.base.storeEntry,
params.base.sessionId,
);
const sessionFamilyKey = resolveUsageFamilyKey({
key: params.base.key,
entry: params.base.storeEntry,
sessionId: params.base.sessionId,
});
params.mergedEntries.push({
...params.base,
scope: "family",
sessionFamilyKey,
currentSessionId: params.base.sessionId,
includedSessionIds,
});
}
function createEmptySessionCostSummary(): SessionCostSummary {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
}
function mergeSessionUsageInto(target: SessionCostSummary, source: SessionCostSummary): void {
target.input += source.input;
target.output += source.output;
target.cacheRead += source.cacheRead;
target.cacheWrite += source.cacheWrite;
target.totalTokens += source.totalTokens;
target.totalCost += source.totalCost;
target.inputCost += source.inputCost;
target.outputCost += source.outputCost;
target.cacheReadCost += source.cacheReadCost;
target.cacheWriteCost += source.cacheWriteCost;
target.missingCostEntries += source.missingCostEntries;
target.firstActivity =
target.firstActivity === undefined
? source.firstActivity
: source.firstActivity === undefined
? target.firstActivity
: Math.min(target.firstActivity, source.firstActivity);
target.lastActivity =
target.lastActivity === undefined
? source.lastActivity
: source.lastActivity === undefined
? target.lastActivity
: Math.max(target.lastActivity, source.lastActivity);
if (target.firstActivity !== undefined && target.lastActivity !== undefined) {
target.durationMs = Math.max(0, target.lastActivity - target.firstActivity);
}
const activityDates = new Set([...(target.activityDates ?? []), ...(source.activityDates ?? [])]);
if (activityDates.size > 0) {
target.activityDates = Array.from(activityDates).toSorted();
}
target.dailyBreakdown = mergeDailyRows(target.dailyBreakdown, source.dailyBreakdown, [
"tokens",
"cost",
]);
target.dailyMessageCounts = mergeDailyRows(target.dailyMessageCounts, source.dailyMessageCounts, [
"total",
"user",
"assistant",
"toolCalls",
"toolResults",
"errors",
]);
target.utcQuarterHourMessageCounts = mergeQuarterRows(
target.utcQuarterHourMessageCounts,
source.utcQuarterHourMessageCounts,
["total", "user", "assistant", "toolCalls", "toolResults", "errors"],
);
target.utcQuarterHourTokenUsage = mergeQuarterRows(
target.utcQuarterHourTokenUsage,
source.utcQuarterHourTokenUsage,
["input", "output", "cacheRead", "cacheWrite", "totalTokens", "totalCost"],
);
target.dailyLatency = mergeDailyLatencyRows(target.dailyLatency, source.dailyLatency);
target.dailyModelUsage = mergeDailyModelRows(target.dailyModelUsage, source.dailyModelUsage);
target.messageCounts = mergeMessageCounts(target.messageCounts, source.messageCounts);
target.toolUsage = mergeToolUsage(target.toolUsage, source.toolUsage);
target.modelUsage = mergeModelUsage(target.modelUsage, source.modelUsage);
target.latency = mergeLatency(target.latency, source.latency);
}
function mergeDailyRows<T extends { date: string }>(
left: T[] | undefined,
right: T[] | undefined,
fields: Array<keyof T>,
): T[] | undefined {
const map = new Map<string, T>();
for (const row of [...(left ?? []), ...(right ?? [])]) {
const existing = map.get(row.date);
if (!existing) {
map.set(row.date, { ...row });
continue;
}
for (const field of fields) {
existing[field] = (((existing[field] as number | undefined) ?? 0) +
((row[field] as number | undefined) ?? 0)) as T[keyof T];
}
}
return map.size > 0
? Array.from(map.values()).toSorted((a, b) => a.date.localeCompare(b.date))
: undefined;
}
function mergeQuarterRows<T extends { date: string; quarterIndex: number }>(
left: T[] | undefined,
right: T[] | undefined,
fields: Array<keyof T>,
): T[] | undefined {
const map = new Map<string, T>();
for (const row of [...(left ?? []), ...(right ?? [])]) {
const key = `${row.date}:${row.quarterIndex}`;
const existing = map.get(key);
if (!existing) {
map.set(key, { ...row });
continue;
}
for (const field of fields) {
existing[field] = (((existing[field] as number | undefined) ?? 0) +
((row[field] as number | undefined) ?? 0)) as T[keyof T];
}
}
return map.size > 0
? Array.from(map.values()).toSorted(
(a, b) => a.date.localeCompare(b.date) || a.quarterIndex - b.quarterIndex,
)
: undefined;
}
function mergeMessageCounts(
left: SessionMessageCounts | undefined,
right: SessionMessageCounts | undefined,
): SessionMessageCounts | undefined {
if (!left && !right) {
return undefined;
}
return {
total: (left?.total ?? 0) + (right?.total ?? 0),
user: (left?.user ?? 0) + (right?.user ?? 0),
assistant: (left?.assistant ?? 0) + (right?.assistant ?? 0),
toolCalls: (left?.toolCalls ?? 0) + (right?.toolCalls ?? 0),
toolResults: (left?.toolResults ?? 0) + (right?.toolResults ?? 0),
errors: (left?.errors ?? 0) + (right?.errors ?? 0),
};
}
function mergeToolUsage(
left: SessionCostSummary["toolUsage"],
right: SessionCostSummary["toolUsage"],
): SessionCostSummary["toolUsage"] {
const map = new Map<string, number>();
for (const tool of [...(left?.tools ?? []), ...(right?.tools ?? [])]) {
map.set(tool.name, (map.get(tool.name) ?? 0) + tool.count);
}
return map.size > 0
? {
totalCalls: Array.from(map.values()).reduce((sum, count) => sum + count, 0),
uniqueTools: map.size,
tools: Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.toSorted((a, b) => b.count - a.count),
}
: undefined;
}
function mergeModelUsage(
left: SessionCostSummary["modelUsage"],
right: SessionCostSummary["modelUsage"],
): SessionCostSummary["modelUsage"] {
const map = new Map<string, SessionModelUsage>();
const mergeTotals = (target: CostUsageSummary["totals"], source: CostUsageSummary["totals"]) => {
target.input += source.input;
target.output += source.output;
target.cacheRead += source.cacheRead;
target.cacheWrite += source.cacheWrite;
target.totalTokens += source.totalTokens;
target.totalCost += source.totalCost;
target.inputCost += source.inputCost;
target.outputCost += source.outputCost;
target.cacheReadCost += source.cacheReadCost;
target.cacheWriteCost += source.cacheWriteCost;
target.missingCostEntries += source.missingCostEntries;
};
for (const entry of [...(left ?? []), ...(right ?? [])]) {
const key = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const existing =
map.get(key) ??
({
provider: entry.provider,
model: entry.model,
count: 0,
totals: createEmptySessionCostSummary(),
} as SessionModelUsage);
existing.count += entry.count;
mergeTotals(existing.totals, entry.totals);
map.set(key, existing);
}
return map.size > 0 ? Array.from(map.values()) : undefined;
}
function mergeLatency(
left: SessionCostSummary["latency"],
right: SessionCostSummary["latency"],
): SessionCostSummary["latency"] {
if (!left && !right) {
return undefined;
}
const leftCount = left?.count ?? 0;
const rightCount = right?.count ?? 0;
const count = leftCount + rightCount;
return {
count,
avgMs:
count > 0 ? ((left?.avgMs ?? 0) * leftCount + (right?.avgMs ?? 0) * rightCount) / count : 0,
p95Ms: Math.max(left?.p95Ms ?? 0, right?.p95Ms ?? 0),
minMs: Math.min(
left?.minMs ?? Number.POSITIVE_INFINITY,
right?.minMs ?? Number.POSITIVE_INFINITY,
),
maxMs: Math.max(left?.maxMs ?? 0, right?.maxMs ?? 0),
};
}
function mergeDailyLatencyRows(
left: SessionCostSummary["dailyLatency"],
right: SessionCostSummary["dailyLatency"],
): SessionCostSummary["dailyLatency"] {
const map = new Map<string, NonNullable<SessionCostSummary["dailyLatency"]>[number]>();
for (const row of [...(left ?? []), ...(right ?? [])]) {
const existing = map.get(row.date);
if (!existing) {
map.set(row.date, { ...row });
continue;
}
const count = existing.count + row.count;
existing.avgMs =
count > 0 ? (existing.avgMs * existing.count + row.avgMs * row.count) / count : 0;
existing.count = count;
existing.p95Ms = Math.max(existing.p95Ms, row.p95Ms);
existing.minMs = Math.min(existing.minMs, row.minMs);
existing.maxMs = Math.max(existing.maxMs, row.maxMs);
}
return map.size > 0
? Array.from(map.values()).toSorted((a, b) => a.date.localeCompare(b.date))
: undefined;
}
function mergeDailyModelRows(
left: SessionCostSummary["dailyModelUsage"],
right: SessionCostSummary["dailyModelUsage"],
): SessionCostSummary["dailyModelUsage"] {
const map = new Map<string, NonNullable<SessionCostSummary["dailyModelUsage"]>[number]>();
for (const row of [...(left ?? []), ...(right ?? [])]) {
const key = `${row.date}:${row.provider ?? "unknown"}:${row.model ?? "unknown"}`;
const existing = map.get(key);
if (!existing) {
map.set(key, { ...row });
continue;
}
existing.tokens += row.tokens;
existing.cost += row.cost;
existing.count += row.count;
}
return map.size > 0
? Array.from(map.values()).toSorted((a, b) => a.date.localeCompare(b.date))
: undefined;
}
async function loadCostUsageSummaryCached(params: {
startMs: number;
endMs: number;
@@ -433,6 +795,7 @@ export const usageHandlers: GatewayRequestHandlers = {
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
range: params?.range,
mode: params?.mode,
utcOffset: params?.utcOffset,
});
@@ -457,28 +820,20 @@ export const usageHandlers: GatewayRequestHandlers = {
const { startMs, endMs } = parseDateRange({
startDate: p.startDate,
endDate: p.endDate,
range: p.range,
mode: p.mode,
utcOffset: p.utcOffset,
});
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = normalizeOptionalString(p.key) ?? null;
const groupingMode: UsageGroupingMode =
p.groupBy === "family" || p.includeHistorical === true ? "family" : "instance";
// Load session store for named sessions
const { storePath, store } = loadCombinedSessionStoreForGateway(config);
const now = Date.now();
// Merge discovered sessions with store entries
type MergedEntry = {
key: string;
sessionId: string;
sessionFile: string;
label?: string;
updatedAt: number;
storeEntry?: SessionEntry;
firstUserMessage?: string;
};
const mergedEntries: MergedEntry[] = [];
// Optimization: If a specific key is requested, skip full directory scan
@@ -525,13 +880,17 @@ export const usageHandlers: GatewayRequestHandlers = {
try {
const stats = fs.statSync(sessionFile);
if (stats.isFile()) {
mergedEntries.push({
key: resolvedStoreKey,
sessionId,
sessionFile,
label: storeEntry?.label,
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
storeEntry,
maybeMergeFamilyEntry({
mergedEntries,
groupingMode,
base: {
key: resolvedStoreKey,
sessionId,
sessionFile,
label: storeEntry?.label,
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
storeEntry,
},
});
}
} catch {
@@ -548,20 +907,35 @@ export const usageHandlers: GatewayRequestHandlers = {
// Build a map of sessionId -> store entry for quick lookup
const storeBySessionId = buildStoreBySessionId(store);
const storeFamilySessionIds = new Set<string>();
if (groupingMode === "family") {
for (const entry of Object.values(store)) {
for (const sessionId of entry?.usageFamilySessionIds ?? []) {
storeFamilySessionIds.add(sessionId);
}
}
}
for (const discovered of discoveredSessions) {
const storeMatch = storeBySessionId.get(discovered.sessionId);
if (storeMatch) {
// Named session from store
mergedEntries.push({
key: storeMatch.key,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: storeMatch.entry.label,
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
storeEntry: storeMatch.entry,
maybeMergeFamilyEntry({
mergedEntries,
groupingMode,
base: {
key: storeMatch.key,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: storeMatch.entry.label,
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
storeEntry: storeMatch.entry,
},
});
} else {
if (groupingMode === "family" && storeFamilySessionIds.has(discovered.sessionId)) {
continue;
}
// Unnamed session - use session ID as key, no label
mergedEntries.push({
// Keep agentId in the key so the dashboard can attribute sessions and later fetch logs.
@@ -570,6 +944,7 @@ export const usageHandlers: GatewayRequestHandlers = {
sessionFile: discovered.sessionFile,
label: undefined, // No label for unnamed sessions
updatedAt: discovered.mtime,
scope: "instance",
});
}
}
@@ -666,18 +1041,42 @@ export const usageHandlers: GatewayRequestHandlers = {
for (const merged of limitedEntries) {
const agentId = parseAgentSessionKey(merged.key)?.agentId;
const cachedUsage = await loadSessionCostSummaryFromCache({
sessionId: merged.sessionId,
sessionEntry: merged.storeEntry,
sessionFile: merged.sessionFile,
config,
agentId,
startMs,
endMs,
refreshMode: "sync-when-empty",
});
cacheStatus = mergeUsageCacheStatus(cacheStatus, cachedUsage.cacheStatus);
const usage = cachedUsage.summary;
let usage: SessionCostSummary | null = null;
const includedSessionIds = merged.includedSessionIds ?? [merged.sessionId];
for (const includedSessionId of includedSessionIds) {
const isCurrentSession = includedSessionId === merged.sessionId;
const includedSessionFile = isCurrentSession
? merged.sessionFile
: resolveExistingUsageSessionFile({
sessionId: includedSessionId,
config,
agentId,
});
if (!includedSessionFile) {
continue;
}
const cachedUsage = await loadSessionCostSummaryFromCache({
sessionId: includedSessionId,
sessionEntry: isCurrentSession ? merged.storeEntry : undefined,
sessionFile: includedSessionFile,
config,
agentId,
startMs,
endMs,
refreshMode: "sync-when-empty",
});
cacheStatus = mergeUsageCacheStatus(cacheStatus, cachedUsage.cacheStatus);
const includedUsage = cachedUsage.summary;
if (!includedUsage) {
continue;
}
if (!usage) {
usage = createEmptySessionCostSummary();
usage.sessionId = merged.sessionId;
usage.sessionFile = merged.sessionFile;
}
mergeSessionUsageInto(usage, includedUsage);
}
if (usage) {
aggregateTotals.input += usage.input;
@@ -815,6 +1214,11 @@ export const usageHandlers: GatewayRequestHandlers = {
key: merged.key,
label: merged.label,
sessionId: merged.sessionId,
scope: merged.scope ?? "instance",
sessionFamilyKey: merged.sessionFamilyKey,
currentSessionId: merged.currentSessionId,
includedSessionIds: merged.includedSessionIds,
historicalInstanceCount: merged.includedSessionIds?.length,
updatedAt: merged.updatedAt,
agentId,
channel,

View File

@@ -14,6 +14,11 @@ export type SessionUsageEntry = {
key: string;
label?: string;
sessionId?: string;
scope?: "instance" | "family";
sessionFamilyKey?: string;
currentSessionId?: string;
includedSessionIds?: string[];
historicalInstanceCount?: number;
updatedAt?: number;
agentId?: string;
channel?: string;

View File

@@ -701,6 +701,16 @@ export const en: TranslationMap = {
today: "Today",
last7d: "7d",
last30d: "30d",
last90d: "90d",
last1y: "1y",
all: "All",
},
scope: {
instance: "Current instance",
instanceHint: "Show only the active session id for each logical session.",
family: "Historical lineage",
familyHint: "Roll up known rotated transcript-backed session ids.",
familyIncluded: "Historical lineage includes {count} session instances.",
},
filters: {
title: "Filters",

View File

@@ -1311,6 +1311,12 @@ details.usage-filter-select summary::-webkit-details-marker,
font-size: 11px;
}
.usage-lineage-note {
color: var(--muted);
font-size: 12px;
margin-top: -6px;
}
.session-detail-stats {
display: flex;
flex-wrap: wrap;

View File

@@ -62,6 +62,7 @@ export function renderUsageTab(state: AppViewState) {
filters: {
startDate: state.usageStartDate,
endDate: state.usageEndDate,
scope: state.usageScope,
selectedSessions: state.usageSelectedSessions,
selectedDays: state.usageSelectedDays,
selectedHours: state.usageSelectedHours,
@@ -113,6 +114,15 @@ export function renderUsageTab(state: AppViewState) {
state.usageSelectedSessions = [];
debouncedLoadUsage(state);
},
onScopeChange: (scope) => {
state.usageScope = scope;
state.usageSelectedDays = [];
state.usageSelectedHours = [];
state.usageSelectedSessions = [];
state.usageTimeSeries = null;
state.usageSessionLogs = null;
void loadUsage(state);
},
onRefresh: () => loadUsage(state),
onTimeZoneChange: (zone) => {
state.usageTimeZone = zone;

View File

@@ -285,6 +285,7 @@ export type AppViewState = {
usageError: string | null;
usageStartDate: string;
usageEndDate: string;
usageScope: "instance" | "family";
usageSelectedSessions: string[];
usageSelectedDays: string[];
usageSelectedHours: number[];

View File

@@ -412,6 +412,7 @@ export class OpenClawApp extends LitElement {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
})();
@state() usageScope: "instance" | "family" = "family";
@state() usageSelectedSessions: string[] = [];
@state() usageSelectedDays: string[] = [];
@state() usageSelectedHours: number[] = [];

View File

@@ -20,6 +20,7 @@ function createState(request: RequestFn, overrides: Partial<UsageState> = {}): U
usageError: null,
usageStartDate: "2026-02-16",
usageEndDate: "2026-02-16",
usageScope: "family",
usageSelectedSessions: [],
usageSelectedDays: [],
usageTimeSeries: null,
@@ -39,6 +40,8 @@ function expectSpecificTimezoneCalls(request: ReturnType<typeof vi.fn>, startCal
endDate: "2026-02-16",
mode: "specific",
utcOffset: "UTC+5:30",
groupBy: "family",
includeHistorical: true,
limit: 1000,
includeContextWeight: true,
});
@@ -85,6 +88,8 @@ describe("usage controller date interpretation params", () => {
startDate: "2026-02-16",
endDate: "2026-02-16",
mode: "utc",
groupBy: "family",
includeHistorical: true,
limit: 1000,
includeContextWeight: true,
});
@@ -139,6 +144,8 @@ describe("usage controller date interpretation params", () => {
expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
groupBy: "family",
includeHistorical: true,
limit: 1000,
includeContextWeight: true,
});
@@ -153,6 +160,8 @@ describe("usage controller date interpretation params", () => {
expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", {
startDate: "2026-02-16",
endDate: "2026-02-16",
groupBy: "family",
includeHistorical: true,
limit: 1000,
includeContextWeight: true,
});

View File

@@ -17,6 +17,7 @@ export type UsageState = {
usageError: string | null;
usageStartDate: string;
usageEndDate: string;
usageScope: "instance" | "family";
usageSelectedSessions: string[];
usageSelectedDays: string[];
usageTimeSeries: SessionUsageTimeSeries | null;
@@ -186,6 +187,8 @@ export async function loadUsage(
startDate,
endDate,
...dateInterpretation,
groupBy: state.usageScope,
includeHistorical: state.usageScope === "family",
limit: 1000, // Cap at 1000 sessions
includeContextWeight: true,
}),

View File

@@ -301,6 +301,15 @@ function renderSessionDetailPanel(
×
</button>
</div>
${session.scope === "family" && session.includedSessionIds?.length
? html`
<div class="usage-lineage-note">
${t("usage.scope.familyIncluded", {
count: String(session.includedSessionIds.length),
})}
</div>
`
: nothing}
<div class="session-detail-content">
${renderSessionSummary(
session,

View File

@@ -316,6 +316,8 @@ export function renderUsage(props: UsageProps) {
{ label: t("usage.presets.today"), days: 1 },
{ label: t("usage.presets.last7d"), days: 7 },
{ label: t("usage.presets.last30d"), days: 30 },
{ label: t("usage.presets.last90d"), days: 90 },
{ label: t("usage.presets.last1y"), days: 365 },
];
const applyPreset = (days: number) => {
const end = new Date();
@@ -324,6 +326,10 @@ export function renderUsage(props: UsageProps) {
filterActions.onStartDateChange(formatIsoDate(start));
filterActions.onEndDateChange(formatIsoDate(end));
};
const applyAllRange = () => {
filterActions.onStartDateChange("1970-01-01");
filterActions.onEndDateChange(formatIsoDate(new Date()));
};
const renderFilterSelect = (key: string, label: string, options: string[]) => {
if (options.length === 0) {
return nothing;
@@ -550,6 +556,7 @@ export function renderUsage(props: UsageProps) {
</button>
`,
)}
<button class="btn btn--sm" @click=${applyAllRange}>${t("usage.presets.all")}</button>
</div>
<div class="usage-date-range">
<input
@@ -585,6 +592,22 @@ export function renderUsage(props: UsageProps) {
<option value="local">${t("usage.filters.timeZoneLocal")}</option>
<option value="utc">${t("usage.filters.timeZoneUtc")}</option>
</select>
<div class="chart-toggle">
<button
class="btn btn--sm toggle-btn ${filters.scope === "instance" ? "active" : ""}"
title=${t("usage.scope.instanceHint")}
@click=${() => filterActions.onScopeChange("instance")}
>
${t("usage.scope.instance")}
</button>
<button
class="btn btn--sm toggle-btn ${filters.scope === "family" ? "active" : ""}"
title=${t("usage.scope.familyHint")}
@click=${() => filterActions.onScopeChange("family")}
>
${t("usage.scope.family")}
</button>
</div>
<div class="chart-toggle">
<button
class="btn btn--sm toggle-btn ${isTokenMode ? "active" : ""}"

View File

@@ -37,6 +37,7 @@ export type UsageDataState = {
export type UsageFilterState = {
startDate: string;
endDate: string;
scope: "instance" | "family";
selectedSessions: string[]; // Support multiple session selection
selectedDays: string[]; // Support multiple day selection
selectedHours: number[]; // Support multiple hour selection
@@ -79,6 +80,7 @@ export type UsageCallbacks = {
filters: {
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
onScopeChange: (scope: "instance" | "family") => void;
onRefresh: () => void;
onTimeZoneChange: (zone: "local" | "utc") => void;
onToggleHeaderPinned: () => void;