From 039eee32ec67d2783a83ac2d50ade30ff816e991 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 2 May 2026 09:46:24 -0500 Subject: [PATCH] fix(usage): roll up session lineage history --- CHANGELOG.md | 1 + .../reply/agent-runner-session-reset.ts | 4 + src/auto-reply/reply/session-updates.ts | 4 + src/config/sessions/types.ts | 4 + src/gateway/protocol/schema/sessions.ts | 14 + .../usage.sessions-usage.test.ts | 84 +++ src/gateway/server-methods/usage.ts | 478 ++++++++++++++++-- src/shared/usage-types.ts | 5 + ui/src/i18n/locales/en.ts | 10 + ui/src/styles/usage.css | 6 + ui/src/ui/app-render-usage-tab.ts | 10 + ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/controllers/usage.node.test.ts | 9 + ui/src/ui/controllers/usage.ts | 3 + ui/src/ui/views/usage-render-details.ts | 9 + ui/src/ui/views/usage.ts | 23 + ui/src/ui/views/usageTypes.ts | 2 + 18 files changed, 631 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13be662eff9..6a0db55cda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index 55df3cbc5a1..b9453c57006 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -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, diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 2e2f09edcbb..42fa61f8139 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -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; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index e9b0a16e8eb..3786f40ab98 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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. */ diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 172c5323c51..626ab842ce5 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -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). */ diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 2cb0b938ef7..9cebdf9d2cc 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -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-")); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 38252197df7..5c731ca7b86 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -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, @@ -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[] { + 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( + left: T[] | undefined, + right: T[] | undefined, + fields: Array, +): T[] | undefined { + const map = new Map(); + 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( + left: T[] | undefined, + right: T[] | undefined, + fields: Array, +): T[] | undefined { + const map = new Map(); + 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(); + 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(); + 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[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[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(); + 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, diff --git a/src/shared/usage-types.ts b/src/shared/usage-types.ts index 0a00982aa62..a9cdc2f8b42 100644 --- a/src/shared/usage-types.ts +++ b/src/shared/usage-types.ts @@ -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; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 84e03ad18be..5dacd0c35c1 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index 91608dcc965..75b056089c1 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -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; diff --git a/ui/src/ui/app-render-usage-tab.ts b/ui/src/ui/app-render-usage-tab.ts index 4b57b583241..105d6f40b82 100644 --- a/ui/src/ui/app-render-usage-tab.ts +++ b/ui/src/ui/app-render-usage-tab.ts @@ -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; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 5b57748c1a6..37902e88ce2 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -285,6 +285,7 @@ export type AppViewState = { usageError: string | null; usageStartDate: string; usageEndDate: string; + usageScope: "instance" | "family"; usageSelectedSessions: string[]; usageSelectedDays: string[]; usageSelectedHours: number[]; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index e0b3ca780ea..a950ea0bd88 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[] = []; diff --git a/ui/src/ui/controllers/usage.node.test.ts b/ui/src/ui/controllers/usage.node.test.ts index 378c2db4c55..19272f599cb 100644 --- a/ui/src/ui/controllers/usage.node.test.ts +++ b/ui/src/ui/controllers/usage.node.test.ts @@ -20,6 +20,7 @@ function createState(request: RequestFn, overrides: Partial = {}): 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, 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, }); diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 71ddb096601..f80bfcf0400 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -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, }), diff --git a/ui/src/ui/views/usage-render-details.ts b/ui/src/ui/views/usage-render-details.ts index 703715df1a2..51f1143475b 100644 --- a/ui/src/ui/views/usage-render-details.ts +++ b/ui/src/ui/views/usage-render-details.ts @@ -301,6 +301,15 @@ function renderSessionDetailPanel( × + ${session.scope === "family" && session.includedSessionIds?.length + ? html` +
+ ${t("usage.scope.familyIncluded", { + count: String(session.includedSessionIds.length), + })} +
+ ` + : nothing}
${renderSessionSummary( session, diff --git a/ui/src/ui/views/usage.ts b/ui/src/ui/views/usage.ts index 0f306b087c0..9265b8858a3 100644 --- a/ui/src/ui/views/usage.ts +++ b/ui/src/ui/views/usage.ts @@ -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) { `, )} +
${t("usage.filters.timeZoneLocal")} +
+ + +