mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 04:50:44 +00:00
fix(usage): roll up session lineage history
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -285,6 +285,7 @@ export type AppViewState = {
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageScope: "instance" | "family";
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageSelectedHours: number[];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" : ""}"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user