Web UI: add token usage dashboard (#10072)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature

- Restore normalizeGatewayUrl() to validate ws:/wss: protocol
- Restore isTopLevelWindow() guard for iframe security
- Revert syncUrlWithSessionKey signature (host param was unused)

* feat(ui): Token Usage dashboard with session analytics

Adds a comprehensive Token Usage view to the dashboard:

Backend:
- Extended session-cost-usage.ts with per-session daily breakdown
- Added date range filtering (startMs/endMs) to API endpoints
- New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints
- Cost breakdown by token type (input/output/cache read/write)

Frontend:
- Two-column layout: Daily chart + breakdown | Sessions list
- Interactive daily bar chart with click-to-filter and shift-click range select
- Session detail panel with usage timeline, conversation logs, context weight
- Filter chips for active day/session selections
- Toggle between tokens/cost view modes (default: cost)
- Responsive design for smaller screens

UX improvements:
- 21-day default date range
- Debounced date input (400ms)
- Session list shows filtered totals when days selected
- Context weight breakdown shows skills, tools, files contribution

* fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj)

* Usage: enrich metrics dashboard

* Usage: add latency + model trends

* Gateway: improve usage log parsing

* UI: add usage query helpers

* UI: client-side usage filter + debounce

* Build: harden write-cli-compat timing

* UI: add conversation log filters

* UI: fix usage dashboard lint + state

* Web UI: default usage dates to local day

* Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman)

---------

Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
Tak Hoffman
2026-02-05 22:35:46 -06:00
committed by GitHub
parent b40da2cb7a
commit 8a352c8f9d
28 changed files with 8663 additions and 387 deletions

View File

@@ -161,6 +161,8 @@ import {
SessionsResetParamsSchema,
type SessionsResolveParams,
SessionsResolveParamsSchema,
type SessionsUsageParams,
SessionsUsageParamsSchema,
type ShutdownEvent,
ShutdownEventSchema,
type SkillsBinsParams,
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
SessionsCompactParamsSchema,
);
export const validateSessionsUsageParams =
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
@@ -412,6 +416,7 @@ export {
SessionsResetParamsSchema,
SessionsDeleteParamsSchema,
SessionsCompactParamsSchema,
SessionsUsageParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ConfigApplyParamsSchema,
@@ -541,6 +546,7 @@ export type {
SessionsResetParams,
SessionsDeleteParams,
SessionsCompactParams,
SessionsUsageParams,
CronJob,
CronListParams,
CronStatusParams,

View File

@@ -117,6 +117,7 @@ import {
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
SessionsUsageParamsSchema,
} from "./sessions.js";
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import {
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsResetParams: SessionsResetParamsSchema,
SessionsDeleteParams: SessionsDeleteParamsSchema,
SessionsCompactParams: SessionsCompactParamsSchema,
SessionsUsageParams: SessionsUsageParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ConfigApplyParams: ConfigApplyParamsSchema,

View File

@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const SessionsUsageParamsSchema = Type.Object(
{
/** Specific session key to analyze; if omitted returns all sessions. */
key: Type.Optional(NonEmptyString),
/** Start date for range filter (YYYY-MM-DD). */
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** End date for range filter (YYYY-MM-DD). */
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** Maximum sessions to return (default 50). */
limit: Type.Optional(Type.Integer({ minimum: 1 })),
/** Include context weight breakdown (systemPromptReport). */
includeContextWeight: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -110,6 +110,7 @@ import type {
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
SessionsUsageParamsSchema,
} from "./sessions.js";
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import type {
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../infra/session-cost-usage.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/session-cost-usage.js")>(
"../../infra/session-cost-usage.js",
);
return {
...actual,
loadCostUsageSummary: vi.fn(async () => ({
updatedAt: Date.now(),
startDate: "2026-02-01",
endDate: "2026-02-02",
daily: [],
totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 },
})),
};
});
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import { __test } from "./usage.js";
describe("gateway usage helpers", () => {
beforeEach(() => {
__test.costUsageCache.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => {
expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5));
expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5));
expect(__test.parseDateToMs("2026-2-5")).toBeUndefined();
expect(__test.parseDateToMs("nope")).toBeUndefined();
expect(__test.parseDateToMs(undefined)).toBeUndefined();
});
it("parseDays coerces strings/numbers to integers", () => {
expect(__test.parseDays(7.9)).toBe(7);
expect(__test.parseDays("30")).toBe(30);
expect(__test.parseDays("")).toBeUndefined();
expect(__test.parseDays("nope")).toBeUndefined();
});
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
});
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
const oneDay = __test.parseDateRange({ days: 0 });
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
const def = __test.parseDateRange({});
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
});
it("loadCostUsageSummaryCached caches within TTL", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));
const config = {} as unknown as ReturnType<import("../../config/config.js").loadConfig>;
const a = await __test.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
config,
});
const b = await __test.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
config,
});
expect(a.totals.totalTokens).toBe(1);
expect(b.totals.totalTokens).toBe(1);
expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,20 +1,68 @@
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import fs from "node:fs";
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
import type {
CostUsageSummary,
SessionCostSummary,
SessionDailyLatency,
SessionDailyModelUsage,
SessionMessageCounts,
SessionLatencyStats,
SessionModelUsage,
SessionToolUsage,
} from "../../infra/session-cost-usage.js";
import type { GatewayRequestHandlers } from "./types.js";
import { loadConfig } from "../../config/config.js";
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import {
loadCostUsageSummary,
loadSessionCostSummary,
loadSessionUsageTimeSeries,
discoverAllSessions,
} from "../../infra/session-cost-usage.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateSessionsUsageParams,
} from "../protocol/index.js";
import { loadCombinedSessionStoreForGateway, loadSessionEntry } from "../session-utils.js";
const COST_USAGE_CACHE_TTL_MS = 30_000;
type DateRange = { startMs: number; endMs: number };
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
updatedAt?: number;
inFlight?: Promise<CostUsageSummary>;
};
const costUsageCache = new Map<number, CostUsageCacheEntry>();
const costUsageCache = new Map<string, CostUsageCacheEntry>();
const parseDays = (raw: unknown): number => {
/**
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
* Returns undefined if invalid.
*/
const parseDateToMs = (raw: unknown): number | undefined => {
if (typeof raw !== "string" || !raw.trim()) {
return undefined;
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
if (!match) {
return undefined;
}
const [, year, month, day] = match;
// Use UTC to ensure consistent behavior across timezones
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
if (Number.isNaN(ms)) {
return undefined;
}
return ms;
};
const parseDays = (raw: unknown): number | undefined => {
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.floor(raw);
}
@@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => {
return Math.floor(parsed);
}
}
return 30;
return undefined;
};
/**
* Get date range from params (startDate/endDate or days).
* Falls back to last 30 days if not provided.
*/
const parseDateRange = (params: {
startDate?: unknown;
endDate?: unknown;
days?: unknown;
}): DateRange => {
const now = new Date();
// Use UTC for consistent date handling
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
const startMs = parseDateToMs(params.startDate);
const endMs = parseDateToMs(params.endDate);
if (startMs !== undefined && endMs !== undefined) {
// endMs should be end of day
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
}
const days = parseDays(params.days);
if (days !== undefined) {
const clampedDays = Math.max(1, days);
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
return { startMs: start, endMs: todayEndMs };
}
// Default to last 30 days
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
return { startMs: defaultStartMs, endMs: todayEndMs };
};
async function loadCostUsageSummaryCached(params: {
days: number;
startMs: number;
endMs: number;
config: ReturnType<typeof loadConfig>;
}): Promise<CostUsageSummary> {
const days = Math.max(1, params.days);
const cacheKey = `${params.startMs}-${params.endMs}`;
const now = Date.now();
const cached = costUsageCache.get(days);
const cached = costUsageCache.get(cacheKey);
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
return cached.summary;
}
@@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: {
}
const entry: CostUsageCacheEntry = cached ?? {};
const inFlight = loadCostUsageSummary({ days, config: params.config })
const inFlight = loadCostUsageSummary({
startMs: params.startMs,
endMs: params.endMs,
config: params.config,
})
.then((summary) => {
costUsageCache.set(days, { summary, updatedAt: Date.now() });
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
return summary;
})
.catch((err) => {
@@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: {
throw err;
})
.finally(() => {
const current = costUsageCache.get(days);
const current = costUsageCache.get(cacheKey);
if (current?.inFlight === inFlight) {
current.inFlight = undefined;
costUsageCache.set(days, current);
costUsageCache.set(cacheKey, current);
}
});
entry.inFlight = inFlight;
costUsageCache.set(days, entry);
costUsageCache.set(cacheKey, entry);
if (entry.summary) {
return entry.summary;
@@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: {
return await inFlight;
}
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
export const __test = {
parseDateToMs,
parseDays,
parseDateRange,
loadCostUsageSummaryCached,
costUsageCache,
};
export type SessionUsageEntry = {
key: string;
label?: string;
sessionId?: string;
updatedAt?: number;
agentId?: string;
channel?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
modelOverride?: string;
providerOverride?: string;
modelProvider?: string;
model?: string;
usage: SessionCostSummary | null;
contextWeight?: SessionSystemPromptReport | null;
};
export type SessionsUsageAggregates = {
messages: SessionMessageCounts;
tools: SessionToolUsage;
byModel: SessionModelUsage[];
byProvider: SessionModelUsage[];
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
latency?: SessionLatencyStats;
dailyLatency?: SessionDailyLatency[];
modelDaily?: SessionDailyModelUsage[];
daily: Array<{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}>;
};
export type SessionsUsageResult = {
updatedAt: number;
startDate: string;
endDate: string;
sessions: SessionUsageEntry[];
totals: CostUsageSummary["totals"];
aggregates: SessionsUsageAggregates;
};
export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => {
const summary = await loadProviderUsageSummary();
@@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = {
},
"usage.cost": async ({ respond, params }) => {
const config = loadConfig();
const days = parseDays(params?.days);
const summary = await loadCostUsageSummaryCached({ days, config });
const { startMs, endMs } = parseDateRange({
startDate: params?.startDate,
endDate: params?.endDate,
days: params?.days,
});
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
respond(true, summary, undefined);
},
"sessions.usage": async ({ respond, params }) => {
if (!validateSessionsUsageParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`,
),
);
return;
}
const p = params;
const config = loadConfig();
const { startMs, endMs } = parseDateRange({
startDate: p.startDate,
endDate: p.endDate,
});
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
// Load session store for named sessions
const { 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
if (specificKey) {
// Check if it's a named session in the store
const storeEntry = store[specificKey];
let sessionId = storeEntry?.sessionId ?? specificKey;
// Resolve the session file path
const sessionFile = resolveSessionFilePath(sessionId, storeEntry);
try {
const stats = fs.statSync(sessionFile);
if (stats.isFile()) {
mergedEntries.push({
key: specificKey,
sessionId,
sessionFile,
label: storeEntry?.label,
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
storeEntry,
});
}
} catch {
// File doesn't exist - no results for this key
}
} else {
// Full discovery for list view
const discoveredSessions = await discoverAllSessions({
startMs,
endMs,
});
// Build a map of sessionId -> store entry for quick lookup
const storeBySessionId = new Map<string, { key: string; entry: SessionEntry }>();
for (const [key, entry] of Object.entries(store)) {
if (entry?.sessionId) {
storeBySessionId.set(entry.sessionId, { key, entry });
}
}
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,
});
} else {
// Unnamed session - use session ID as key, no label
mergedEntries.push({
key: discovered.sessionId,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: undefined, // No label for unnamed sessions
updatedAt: discovered.mtime,
});
}
}
}
// Sort by most recent first
mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt);
// Apply limit
const limitedEntries = mergedEntries.slice(0, limit);
// Load usage for each session
const sessions: SessionUsageEntry[] = [];
const aggregateTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
const aggregateMessages: SessionMessageCounts = {
total: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
};
const toolAggregateMap = new Map<string, number>();
const byModelMap = new Map<string, SessionModelUsage>();
const byProviderMap = new Map<string, SessionModelUsage>();
const byAgentMap = new Map<string, CostUsageSummary["totals"]>();
const byChannelMap = new Map<string, CostUsageSummary["totals"]>();
const dailyAggregateMap = new Map<
string,
{
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
}
>();
const latencyTotals = {
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
const dailyLatencyMap = new Map<
string,
{ date: string; count: number; sum: number; min: number; max: number; p95Max: number }
>();
const modelDailyMap = new Map<string, SessionDailyModelUsage>();
const emptyTotals = (): CostUsageSummary["totals"] => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
});
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 merged of limitedEntries) {
const usage = await loadSessionCostSummary({
sessionId: merged.sessionId,
sessionEntry: merged.storeEntry,
sessionFile: merged.sessionFile,
config,
startMs,
endMs,
});
if (usage) {
aggregateTotals.input += usage.input;
aggregateTotals.output += usage.output;
aggregateTotals.cacheRead += usage.cacheRead;
aggregateTotals.cacheWrite += usage.cacheWrite;
aggregateTotals.totalTokens += usage.totalTokens;
aggregateTotals.totalCost += usage.totalCost;
aggregateTotals.inputCost += usage.inputCost;
aggregateTotals.outputCost += usage.outputCost;
aggregateTotals.cacheReadCost += usage.cacheReadCost;
aggregateTotals.cacheWriteCost += usage.cacheWriteCost;
aggregateTotals.missingCostEntries += usage.missingCostEntries;
}
const agentId = parseAgentSessionKey(merged.key)?.agentId;
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType;
if (usage) {
if (usage.messageCounts) {
aggregateMessages.total += usage.messageCounts.total;
aggregateMessages.user += usage.messageCounts.user;
aggregateMessages.assistant += usage.messageCounts.assistant;
aggregateMessages.toolCalls += usage.messageCounts.toolCalls;
aggregateMessages.toolResults += usage.messageCounts.toolResults;
aggregateMessages.errors += usage.messageCounts.errors;
}
if (usage.toolUsage) {
for (const tool of usage.toolUsage.tools) {
toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count);
}
}
if (usage.modelUsage) {
for (const entry of usage.modelUsage) {
const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const modelExisting =
byModelMap.get(modelKey) ??
({
provider: entry.provider,
model: entry.model,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
modelExisting.count += entry.count;
mergeTotals(modelExisting.totals, entry.totals);
byModelMap.set(modelKey, modelExisting);
const providerKey = entry.provider ?? "unknown";
const providerExisting =
byProviderMap.get(providerKey) ??
({
provider: entry.provider,
model: undefined,
count: 0,
totals: emptyTotals(),
} as SessionModelUsage);
providerExisting.count += entry.count;
mergeTotals(providerExisting.totals, entry.totals);
byProviderMap.set(providerKey, providerExisting);
}
}
if (usage.latency) {
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
if (count > 0) {
latencyTotals.count += count;
latencyTotals.sum += avgMs * count;
latencyTotals.min = Math.min(latencyTotals.min, minMs);
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
}
}
if (usage.dailyLatency) {
for (const day of usage.dailyLatency) {
const existing = dailyLatencyMap.get(day.date) ?? {
date: day.date,
count: 0,
sum: 0,
min: Number.POSITIVE_INFINITY,
max: 0,
p95Max: 0,
};
existing.count += day.count;
existing.sum += day.avgMs * day.count;
existing.min = Math.min(existing.min, day.minMs);
existing.max = Math.max(existing.max, day.maxMs);
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
dailyLatencyMap.set(day.date, existing);
}
}
if (usage.dailyModelUsage) {
for (const entry of usage.dailyModelUsage) {
const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
const existing =
modelDailyMap.get(key) ??
({
date: entry.date,
provider: entry.provider,
model: entry.model,
tokens: 0,
cost: 0,
count: 0,
} as SessionDailyModelUsage);
existing.tokens += entry.tokens;
existing.cost += entry.cost;
existing.count += entry.count;
modelDailyMap.set(key, existing);
}
}
if (agentId) {
const agentTotals = byAgentMap.get(agentId) ?? emptyTotals();
mergeTotals(agentTotals, usage);
byAgentMap.set(agentId, agentTotals);
}
if (channel) {
const channelTotals = byChannelMap.get(channel) ?? emptyTotals();
mergeTotals(channelTotals, usage);
byChannelMap.set(channel, channelTotals);
}
if (usage.dailyBreakdown) {
for (const day of usage.dailyBreakdown) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.tokens += day.tokens;
daily.cost += day.cost;
dailyAggregateMap.set(day.date, daily);
}
}
if (usage.dailyMessageCounts) {
for (const day of usage.dailyMessageCounts) {
const daily = dailyAggregateMap.get(day.date) ?? {
date: day.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
daily.messages += day.total;
daily.toolCalls += day.toolCalls;
daily.errors += day.errors;
dailyAggregateMap.set(day.date, daily);
}
}
}
sessions.push({
key: merged.key,
label: merged.label,
sessionId: merged.sessionId,
updatedAt: merged.updatedAt,
agentId,
channel,
chatType,
origin: merged.storeEntry?.origin,
modelOverride: merged.storeEntry?.modelOverride,
providerOverride: merged.storeEntry?.providerOverride,
modelProvider: merged.storeEntry?.modelProvider,
model: merged.storeEntry?.model,
usage,
contextWeight: includeContextWeight
? (merged.storeEntry?.systemPromptReport ?? null)
: undefined,
});
}
// Format dates back to YYYY-MM-DD strings
const formatDateStr = (ms: number) => {
const d = new Date(ms);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const aggregates: SessionsUsageAggregates = {
messages: aggregateMessages,
tools: {
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),
uniqueTools: toolAggregateMap.size,
tools: Array.from(toolAggregateMap.entries())
.map(([name, count]) => ({ name, count }))
.toSorted((a, b) => b.count - a.count),
},
byModel: Array.from(byModelMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => {
const costDiff = b.totals.totalCost - a.totals.totalCost;
if (costDiff !== 0) {
return costDiff;
}
return b.totals.totalTokens - a.totals.totalTokens;
}),
byAgent: Array.from(byAgentMap.entries())
.map(([id, totals]) => ({ agentId: id, totals }))
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
byChannel: Array.from(byChannelMap.entries())
.map(([name, totals]) => ({ channel: name, totals }))
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
latency:
latencyTotals.count > 0
? {
count: latencyTotals.count,
avgMs: latencyTotals.sum / latencyTotals.count,
minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min,
maxMs: latencyTotals.max,
p95Ms: latencyTotals.p95Max,
}
: undefined,
dailyLatency: Array.from(dailyLatencyMap.values())
.map((entry) => ({
date: entry.date,
count: entry.count,
avgMs: entry.count ? entry.sum / entry.count : 0,
minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min,
maxMs: entry.max,
p95Ms: entry.p95Max,
}))
.toSorted((a, b) => a.date.localeCompare(b.date)),
modelDaily: Array.from(modelDailyMap.values()).toSorted(
(a, b) => a.date.localeCompare(b.date) || b.cost - a.cost,
),
daily: Array.from(dailyAggregateMap.values()).toSorted((a, b) =>
a.date.localeCompare(b.date),
),
};
const result: SessionsUsageResult = {
updatedAt: now,
startDate: formatDateStr(startMs),
endDate: formatDateStr(endMs),
sessions,
totals: aggregateTotals,
aggregates,
};
respond(true, result, undefined);
},
"sessions.usage.timeseries": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"),
);
return;
}
const config = loadConfig();
const { entry } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly
const sessionId = entry?.sessionId ?? key;
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
const timeseries = await loadSessionUsageTimeSeries({
sessionId,
sessionEntry: entry,
sessionFile,
config,
maxPoints: 200,
});
if (!timeseries) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`),
);
return;
}
respond(true, timeseries, undefined);
},
"sessions.usage.logs": async ({ respond, params }) => {
const key = typeof params?.key === "string" ? params.key.trim() : null;
if (!key) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs"));
return;
}
const limit =
typeof params?.limit === "number" && Number.isFinite(params.limit)
? Math.min(params.limit, 1000)
: 200;
const config = loadConfig();
const { entry } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly
const sessionId = entry?.sessionId ?? key;
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
const logs = await loadSessionLogs({
sessionId,
sessionEntry: entry,
sessionFile,
config,
limit,
});
respond(true, { logs: logs ?? [] }, undefined);
},
};

View File

@@ -383,6 +383,43 @@ describe("readSessionPreviewItemsFromTranscript", () => {
expect(result[1]?.text).toContain("call weather");
});
test("detects tool calls from tool_use/tool_call blocks and toolName field", () => {
const sessionId = "preview-session-tools";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
JSON.stringify({
message: {
role: "assistant",
toolName: "camera",
content: [
{ type: "tool_use", name: "read" },
{ type: "tool_call", name: "write" },
],
},
}),
JSON.stringify({ message: { role: "assistant", content: "Done" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readSessionPreviewItemsFromTranscript(
sessionId,
storePath,
undefined,
undefined,
3,
120,
);
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
expect(result[1]?.text).toContain("call");
expect(result[1]?.text).toContain("camera");
expect(result[1]?.text).toContain("read");
// Preview text may not list every tool name; it should at least hint there were multiple calls.
expect(result[1]?.text).toMatch(/\+\d+/);
});
test("truncates preview text to max chars", () => {
const sessionId = "preview-truncate";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { SessionPreviewItem } from "./session-utils.types.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
import { stripEnvelope } from "./chat-sanitize.js";
export function readSessionMessages(
@@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
}
function isToolCall(message: TranscriptPreviewMessage): boolean {
if (message.toolName || message.tool_name) {
return true;
}
if (!Array.isArray(message.content)) {
return false;
}
return message.content.some((entry) => {
if (entry?.name) {
return true;
}
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
return raw === "toolcall" || raw === "tool_call";
});
return hasToolCall(message as Record<string, unknown>);
}
function extractToolNames(message: TranscriptPreviewMessage): string[] {
const names: string[] = [];
if (Array.isArray(message.content)) {
for (const entry of message.content) {
if (typeof entry?.name === "string" && entry.name.trim()) {
names.push(entry.name.trim());
}
}
}
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
if (typeof toolName === "string" && toolName.trim()) {
names.push(toolName.trim());
}
return names;
return extractToolCallNames(message as Record<string, unknown>);
}
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {

View File

@@ -3,7 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
import {
discoverAllSessions,
loadCostUsageSummary,
loadSessionCostSummary,
} from "./session-cost-usage.js";
describe("session cost usage", () => {
it("aggregates daily totals with log cost and pricing fallback", async () => {
@@ -140,4 +144,100 @@ describe("session cost usage", () => {
expect(summary?.totalTokens).toBe(30);
expect(summary?.lastActivity).toBeGreaterThan(0);
});
it("captures message counts, tool usage, and model usage", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-session-meta-"));
const sessionFile = path.join(root, "session.jsonl");
const start = new Date("2026-02-01T10:00:00.000Z");
const end = new Date("2026-02-01T10:05:00.000Z");
const entries = [
{
type: "message",
timestamp: start.toISOString(),
message: {
role: "user",
content: "Hello",
},
},
{
type: "message",
timestamp: end.toISOString(),
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.2",
stopReason: "error",
content: [
{ type: "text", text: "Checking" },
{ type: "tool_use", name: "weather" },
{ type: "tool_result", is_error: true },
],
usage: {
input: 12,
output: 18,
totalTokens: 30,
cost: { total: 0.02 },
},
},
},
];
await fs.writeFile(
sessionFile,
entries.map((entry) => JSON.stringify(entry)).join("\n"),
"utf-8",
);
const summary = await loadSessionCostSummary({ sessionFile });
expect(summary?.messageCounts).toEqual({
total: 2,
user: 1,
assistant: 1,
toolCalls: 1,
toolResults: 1,
errors: 2,
});
expect(summary?.toolUsage?.totalCalls).toBe(1);
expect(summary?.toolUsage?.uniqueTools).toBe(1);
expect(summary?.toolUsage?.tools[0]?.name).toBe("weather");
expect(summary?.modelUsage?.[0]?.provider).toBe("openai");
expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2");
expect(summary?.durationMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.count).toBe(1);
expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000);
expect(summary?.latency?.p95Ms).toBe(5 * 60 * 1000);
expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2");
});
it("does not exclude sessions with mtime after endMs during discovery", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-"));
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-late.jsonl");
await fs.writeFile(sessionFile, "", "utf-8");
const now = Date.now();
await fs.utimes(sessionFile, now / 1000, now / 1000);
const originalState = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
const sessions = await discoverAllSessions({
startMs: now - 7 * 24 * 60 * 60 * 1000,
endMs: now - 24 * 60 * 60 * 1000,
});
expect(sessions.length).toBe(1);
expect(sessions[0]?.sessionId).toBe("sess-late");
} finally {
if (originalState === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalState;
}
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js";
describe("transcript-tools", () => {
describe("extractToolCallNames", () => {
it("extracts tool name from message.toolName/tool_name", () => {
expect(extractToolCallNames({ toolName: " weather " })).toEqual(["weather"]);
expect(extractToolCallNames({ tool_name: "notes" })).toEqual(["notes"]);
});
it("extracts tool call names from content blocks (tool_use/toolcall/tool_call)", () => {
const names = extractToolCallNames({
content: [
{ type: "text", text: "hi" },
{ type: "tool_use", name: "read" },
{ type: "toolcall", name: "exec" },
{ type: "tool_call", name: "write" },
],
});
expect(new Set(names)).toEqual(new Set(["read", "exec", "write"]));
});
it("normalizes type and trims names; de-dupes", () => {
const names = extractToolCallNames({
content: [
{ type: " TOOL_CALL ", name: " read " },
{ type: "tool_call", name: "read" },
{ type: "tool_call", name: "" },
],
toolName: "read",
});
expect(names).toEqual(["read"]);
});
});
describe("hasToolCall", () => {
it("returns true when tool call names exist", () => {
expect(hasToolCall({ toolName: "weather" })).toBe(true);
expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true);
});
it("returns false when no tool calls exist", () => {
expect(hasToolCall({})).toBe(false);
expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false);
});
});
describe("countToolResults", () => {
it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => {
expect(
countToolResults({
content: [
{ type: "tool_result" },
{ type: "tool_result", is_error: true },
{ type: "tool_result_error" },
{ type: "text", text: "ignore" },
],
}),
).toEqual({ total: 3, errors: 1 });
});
it("handles non-array content", () => {
expect(countToolResults({ content: "nope" })).toEqual({ total: 0, errors: 0 });
});
});
});

View File

@@ -0,0 +1,73 @@
type ToolResultCounts = {
total: number;
errors: number;
};
const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]);
const normalizeType = (value: unknown): string => {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
};
export const extractToolCallNames = (message: Record<string, unknown>): string[] => {
const names = new Set<string>();
const toolNameRaw = message.toolName ?? message.tool_name;
if (typeof toolNameRaw === "string" && toolNameRaw.trim()) {
names.add(toolNameRaw.trim());
}
const content = message.content;
if (!Array.isArray(content)) {
return Array.from(names);
}
for (const entry of content) {
if (!entry || typeof entry !== "object") {
continue;
}
const block = entry as Record<string, unknown>;
const type = normalizeType(block.type);
if (!TOOL_CALL_TYPES.has(type)) {
continue;
}
const name = block.name;
if (typeof name === "string" && name.trim()) {
names.add(name.trim());
}
}
return Array.from(names);
};
export const hasToolCall = (message: Record<string, unknown>): boolean =>
extractToolCallNames(message).length > 0;
export const countToolResults = (message: Record<string, unknown>): ToolResultCounts => {
const content = message.content;
if (!Array.isArray(content)) {
return { total: 0, errors: 0 };
}
let total = 0;
let errors = 0;
for (const entry of content) {
if (!entry || typeof entry !== "object") {
continue;
}
const block = entry as Record<string, unknown>;
const type = normalizeType(block.type);
if (!TOOL_RESULT_TYPES.has(type)) {
continue;
}
total += 1;
if (block.is_error === true) {
errors += 1;
}
}
return { total, errors };
};