From 11e2bde399b613aacd8f36610308f33f1fc6af0b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 24 Aug 2026 17:26:43 +0530 Subject: [PATCH] fix(stats): correct aggregation boundaries (#44682) --- packages/cli/src/commands/handlers/stats.ts | 27 ++-- packages/cli/test/stats.test.ts | 24 ++- packages/client/src/effect/api/api.ts | 46 +++--- .../client/src/effect/generated/client.ts | 2 - .../client/src/promise/generated/client.ts | 2 - .../client/src/promise/generated/types.ts | 50 ++----- packages/core/src/session/stats.ts | 99 ++++++++----- packages/core/test/session-stats.test.ts | 100 ++++++++++--- packages/protocol/openapi.json | 137 ++++++++++-------- packages/protocol/src/groups/session.ts | 4 +- packages/schema/src/session-stats.ts | 26 +++- packages/server/src/handlers/session.ts | 8 +- packages/www/openapi.json | 137 ++++++++++-------- packages/www/public/openapi.json | 137 ++++++++++-------- 14 files changed, 465 insertions(+), 334 deletions(-) diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts index 395cb5cd985..3befbc457ee 100644 --- a/packages/cli/src/commands/handlers/stats.ts +++ b/packages/cli/src/commands/handlers/stats.ts @@ -36,9 +36,7 @@ const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input value !== undefined) .join(" · ") - const toolSummary = - toolRate === undefined ? "no tool calls" : `${style(formatPercent(toolRate), primary, options.color)} tool success` + const toolSummary = !toolTotals + ? "tool stats unavailable" + : toolRate === undefined + ? "no tool calls" + : `${style(formatPercent(toolRate), primary, options.color)} tool success` const details = options.models || options.tools || options.cost const empty = stats.sessions === 0 && stats.prompts === 0 && stats.steps === 0 const heading = `${style("opencode stats", primary, options.color)} ${style(`· ${options.label} · ${options.scope}`, "2", options.color)}` @@ -253,9 +255,10 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) { } function renderTools(stats: SessionStatsInfo, limit: number, width: number) { - if (stats.toolUsage.length === 0) return ["TOOL RELIABILITY", " no tool calls"] - const tools = stats.toolUsage.slice(0, limit) - const more = stats.toolUsage.length - tools.length + if (stats.tools.mode !== "detail") return ["TOOL RELIABILITY", " tool details unavailable"] + if (stats.tools.usage.length === 0) return ["TOOL RELIABILITY", " no tool calls"] + const tools = stats.tools.usage.slice(0, limit) + const more = stats.tools.usage.length - tools.length if (width < 68) return [ "TOOL RELIABILITY", @@ -267,7 +270,7 @@ function renderTools(stats: SessionStatsInfo, limit: number, width: number) { ] }), "", - `${formatNumber(stats.tools.succeeded + stats.tools.failed)} finished calls · ${formatNumber(stats.tools.unfinished)} unfinished`, + `${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`, ...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []), ] return [ @@ -283,7 +286,7 @@ function renderTools(stats: SessionStatsInfo, limit: number, width: number) { ) }), "", - `${formatNumber(stats.tools.succeeded + stats.tools.failed)} finished calls · ${formatNumber(stats.tools.unfinished)} unfinished`, + `${formatNumber(stats.tools.totals.succeeded + stats.tools.totals.failed)} finished calls · ${formatNumber(stats.tools.totals.unfinished)} unfinished`, ...(more > 0 ? [`+${more.toLocaleString("en-US")} more tool${more === 1 ? "" : "s"}`] : []), ] } diff --git a/packages/cli/test/stats.test.ts b/packages/cli/test/stats.test.ts index 3089baf96c5..06f2e6c1859 100644 --- a/packages/cli/test/stats.test.ts +++ b/packages/cli/test/stats.test.ts @@ -2,6 +2,12 @@ import { describe, expect, test } from "bun:test" import type { SessionStatsInfo } from "@opencode-ai/client" import { renderStats } from "../src/commands/handlers/stats" +const tools = { + mode: "detail", + totals: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 }, + usage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }], +} satisfies SessionStatsInfo["tools"] + const stats: SessionStatsInfo = { range: { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 0, 8) }, sessions: 2, @@ -10,7 +16,7 @@ const stats: SessionStatsInfo = { steps: 6, tokens: { input: 10_000, output: 2_000, reasoning: 1_000, cache: { read: 5_000, write: 500 } }, cost: 12.34, - tools: { calls: 10, succeeded: 8, failed: 2, unfinished: 0 }, + tools, activeDays: 2, streak: 2, activity: [ @@ -25,7 +31,6 @@ const stats: SessionStatsInfo = { cost: 12.34, }, ], - toolUsage: [{ name: "private_tool", calls: 10, succeeded: 8, failed: 2, unfinished: 0, durationP50: 250 }], } describe("stats rendering", () => { @@ -67,10 +72,11 @@ describe("stats rendering", () => { cost: 1.25, }, ], - toolUsage: [ - ...stats.toolUsage, - { name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 }, - ], + tools: { + mode: "detail", + totals: tools.totals, + usage: [...tools.usage, { name: "grep", calls: 4, succeeded: 4, failed: 0, unfinished: 0, durationP50: 20 }], + }, }, options({ models: true, tools: true, limit: 1 }), ) @@ -100,6 +106,12 @@ describe("stats rendering", () => { expect(output).not.toContain("less ·░▒▓█ more") }) + test("does not present uncollected tools as zero calls", () => { + const output = renderStats({ ...stats, tools: { mode: "none" } }, options()) + expect(output).toContain("tool stats unavailable") + expect(output).not.toContain("no tool calls") + }) + test("labels activity when terminal width truncates the requested range", () => { const output = renderStats( { ...stats, range: { from: Date.UTC(2020, 0, 1), to: Date.UTC(2026, 0, 8) } }, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index c169c01df67..96786e8c99d 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -117,9 +117,7 @@ export type SessionStatsInput = { readonly to?: number | undefined readonly project?: Project.ID | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined } export type SessionStatsOutput = { readonly range: { readonly from: DateTime.Utc; readonly to: DateTime.Utc } @@ -134,12 +132,34 @@ export type SessionStatsOutput = { readonly cache: { readonly read: number; readonly write: number } } readonly cost: number & Brand.Brand<"Money.USD"> - readonly tools: { - readonly calls: number - readonly succeeded: number - readonly failed: number - readonly unfinished: number - } + readonly tools: + | { readonly mode: "none" } + | { + readonly mode: "summary" + readonly totals: { + readonly calls: number + readonly succeeded: number + readonly failed: number + readonly unfinished: number + } + } + | { + readonly mode: "detail" + readonly totals: { + readonly calls: number + readonly succeeded: number + readonly failed: number + readonly unfinished: number + } + readonly usage: ReadonlyArray<{ + readonly name: string + readonly calls: number + readonly succeeded: number + readonly failed: number + readonly unfinished: number + readonly durationP50?: number | undefined + }> + } readonly activeDays: number readonly streak: number readonly activity: ReadonlyArray<{ readonly date: string; readonly steps: number }> @@ -154,14 +174,6 @@ export type SessionStatsOutput = { } readonly cost: number & Brand.Brand<"Money.USD"> }> - readonly toolUsage: ReadonlyArray<{ - readonly name: string - readonly calls: number - readonly succeeded: number - readonly failed: number - readonly unfinished: number - readonly durationP50?: number | undefined - }> } export type SessionStatsOperation = (input?: SessionStatsInput) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index b88b5e2224d..d08a0fc5ca2 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -315,9 +315,7 @@ const EndpointSessionStats = (raw: RawClient["server.session"]) => (input?: Sess to: input?.["to"], project: input?.["project"], timezone: input?.["timezone"], - models: input?.["models"], tools: input?.["tools"], - toolSummary: input?.["toolSummary"], }, }).pipe( Effect.mapError(mapClientError), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3f73c56ec20..8395d1dcb56 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -466,9 +466,7 @@ export function make(options: ClientOptions) { to: input?.["to"], project: input?.["project"], timezone: input?.["timezone"], - models: input?.["models"], tools: input?.["tools"], - toolSummary: input?.["toolSummary"], }, successStatus: 200, declaredStatuses: [400, 401], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 81e5c9672a8..faf3d3dc8e8 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -37,7 +37,7 @@ export type FileDiffInfo = { status: "added" | "deleted" | "modified" } -export type SessionStatsActivity = { date: string; steps: number } +export type SessionStatsToolTotals = { calls: number; succeeded: number; failed: number; unfinished: number } export type SessionStatsToolUsage = { name: string @@ -48,6 +48,8 @@ export type SessionStatsToolUsage = { durationP50?: number } +export type SessionStatsActivity = { date: string; steps: number } + export type SessionMessageAgentSelected = { id: string metadata?: { [x: string]: JsonValue } @@ -433,6 +435,11 @@ export type V2EventServerConnected = { export type SessionRevert = { messageID: string; partID?: string; snapshot?: string; files?: Array } +export type SessionStatsTools = + | { mode: "none" } + | { mode: "summary"; totals: SessionStatsToolTotals } + | { mode: "detail"; totals: SessionStatsToolTotals; usage: Array } + export type SessionStatsModelUsage = { model: ModelRef; steps: number; tokens: TokenUsageInfo; cost: MoneyUSD } export type SessionMessageModelSelected = { @@ -1559,12 +1566,11 @@ export type SessionStatsInfo = { steps: number tokens: TokenUsageInfo cost: MoneyUSD - tools: { calls: number; succeeded: number; failed: number; unfinished: number } + tools: SessionStatsTools activeDays: number streak: number activity: Array models: Array - toolUsage: Array } export type SessionMessageUser = { @@ -2481,64 +2487,36 @@ export type SessionStatsInput = { readonly to?: number | undefined readonly project?: string | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined }["from"] readonly to?: { readonly from?: number | undefined readonly to?: number | undefined readonly project?: string | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined }["to"] readonly project?: { readonly from?: number | undefined readonly to?: number | undefined readonly project?: string | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined }["project"] readonly timezone?: { readonly from?: number | undefined readonly to?: number | undefined readonly project?: string | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined }["timezone"] - readonly models?: { - readonly from?: number | undefined - readonly to?: number | undefined - readonly project?: string | undefined - readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined - }["models"] readonly tools?: { readonly from?: number | undefined readonly to?: number | undefined readonly project?: string | undefined readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined + readonly tools?: "none" | "summary" | "detail" | undefined }["tools"] - readonly toolSummary?: { - readonly from?: number | undefined - readonly to?: number | undefined - readonly project?: string | undefined - readonly timezone?: string | undefined - readonly models?: boolean | undefined - readonly tools?: boolean | undefined - readonly toolSummary?: boolean | undefined - }["toolSummary"] } export type SessionStatsOutput = { data: SessionStatsInfo }["data"] diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts index e65fa3171a8..4840e17a5fa 100644 --- a/packages/core/src/session/stats.ts +++ b/packages/core/src/session/stats.ts @@ -7,6 +7,7 @@ import { Money } from "@opencode-ai/schema/money" import { Project } from "@opencode-ai/schema/project" import { Provider } from "@opencode-ai/schema/provider" import { SessionEvent } from "@opencode-ai/schema/session-event" +import { ToolMode } from "@opencode-ai/schema/session-stats" import { Database } from "../database/database.js" import { EventTable } from "../event/sql.js" import { SessionMessageTable, SessionTable } from "./sql.js" @@ -16,9 +17,7 @@ type Input = { readonly to?: number readonly projectID?: Project.ID readonly timezone?: string - readonly models?: boolean - readonly tools?: boolean - readonly toolSummary?: boolean + readonly tools?: ToolMode } type Tokens = { @@ -71,20 +70,36 @@ type ToolAggregate = { const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data) const Window = 31 * 24 * 60 * 60 * 1_000 +export class InvalidRangeError extends Schema.TaggedError()("SessionStats.InvalidRangeError", { + from: Schema.Finite, + to: Schema.Finite, +}) {} + export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { const db = (yield* Database.Service).db - const now = Date.now() - const to = input.to ?? now - const earliest = + const to = input.to ?? Date.now() + if (input.from !== undefined && input.from >= to) return yield* new InvalidRangeError({ from: input.from, to }) + const project = input.projectID === undefined ? sql`` : sql`AND session.project_id = ${input.projectID}` + const from = input.from ?? (yield* db - .get<{ time: number | null }>(sql`SELECT min(time_created) AS time FROM ${SessionMessageTable}`) + .get<{ time: number | null }>( + sql` + SELECT min(message.time_created) AS time + FROM ${SessionMessageTable} AS message + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.type IN ('user', 'assistant') + AND message.time_created < ${to} + AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + ${project} + `, + ) .pipe(Effect.orDie))?.time ?? to - const ranges = windows(earliest, to) + const ranges = windows(from, to) + const toolMode = input.tools ?? "summary" const sessions = new Set() const subagents = new Set() - const sessionIDs = new Set() const activity = new Map() const models = new Map() const tools = new Map() @@ -93,10 +108,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { steps: 0, tokens: emptyTokens(), cost: 0, - tools: { calls: 0, succeeded: 0, failed: 0, unfinished: 0 }, } + const toolTotals = { calls: 0, succeeded: 0, failed: 0, unfinished: 0 } const dateKey = makeDateKey(input.timezone) - const project = input.projectID === undefined ? sql`` : sql`AND session.project_id = ${input.projectID}` yield* Effect.forEach( ranges, @@ -132,7 +146,6 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { Effect.tap((rows) => Effect.sync(() => { rows.forEach((row) => { - sessionIDs.add(row.sessionID) if (row.parentID === null) sessions.add(row.sessionID) else subagents.add(row.sessionID) if (row.type === "user") { @@ -146,7 +159,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { totals.cost += row.cost ?? 0 const day = dateKey(row.timeCreated) activity.set(day, (activity.get(day) ?? 0) + 1) - if (!input.models || !row.providerID || !row.modelID) return + if (!row.providerID || !row.modelID) return const key = `${row.providerID}/${row.modelID}#${row.variant ?? ""}` const model = models.get(key) ?? { model: { @@ -169,11 +182,11 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { { concurrency: 1, discard: true }, ) - if (input.tools || input.toolSummary) + if (toolMode !== "none") yield* Effect.forEach( ranges, (range) => { - if (!input.tools) + if (toolMode === "summary") return db .all( sql` @@ -193,7 +206,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { .pipe( Effect.orDie, Effect.tap((rows) => - Effect.sync(() => rows.forEach((row) => addToolStatus(totals.tools, row.status, row.count))), + Effect.sync(() => rows.forEach((row) => addToolStatus(toolTotals, row.status, row.count))), ), Effect.asVoid, ) @@ -224,7 +237,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { Effect.tap((rows) => Effect.sync(() => { rows.forEach((row) => { - addToolStatus(totals.tools, row.status, 1) + addToolStatus(toolTotals, row.status, 1) if (!row.name) return const tool = tools.get(row.name) ?? { name: row.name, @@ -246,9 +259,14 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { { concurrency: 1, discard: true }, ) - const ids = [...sessionIDs] + const ids = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(input.projectID === undefined ? undefined : eq(SessionTable.project_id, input.projectID)) + .all() + .pipe(Effect.orDie) const events = (yield* Effect.forEach( - Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) => ids.slice(index * 500, (index + 1) * 500)), + batches(ids.map((row) => row.id)), (batch) => db .select({ created: EventTable.created, data: EventTable.data }) @@ -257,8 +275,9 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { and( inArray(EventTable.aggregate_id, batch), eq(EventTable.type, SessionEvent.UsageRecorded.type), - input.from === undefined ? undefined : gte(EventTable.created, input.from), - input.to === undefined ? undefined : lt(EventTable.created, input.to), + sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`, + gte(EventTable.created, from), + lt(EventTable.created, to), ), ) .all() @@ -274,40 +293,52 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { const days = [...activity.entries()].sort(([a], [b]) => a.localeCompare(b)) return { - range: { from: DateTime.makeUnsafe(earliest), to: DateTime.makeUnsafe(to) }, + range: { from: DateTime.makeUnsafe(from), to: DateTime.makeUnsafe(to) }, sessions: sessions.size, subagents: subagents.size, prompts: totals.prompts, steps: totals.steps, tokens: totals.tokens, cost: Money.USD.make(totals.cost), - tools: totals.tools, + tools: + toolMode === "none" + ? { mode: toolMode } + : toolMode === "summary" + ? { mode: toolMode, totals: toolTotals } + : { + mode: toolMode, + totals: toolTotals, + usage: [...tools.values()] + .sort((a, b) => b.calls - a.calls) + .map((tool) => ({ + name: tool.name, + calls: tool.calls, + succeeded: tool.succeeded, + failed: tool.failed, + unfinished: tool.unfinished, + durationP50: median(tool.durations), + })), + }, activeDays: days.length, streak: longestStreak(days.map(([date]) => date)), activity: days.map(([date, steps]) => ({ date, steps })), models: [...models.values()] .sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens)) .map((model) => ({ ...model, cost: Money.USD.make(model.cost) })), - toolUsage: [...tools.values()] - .sort((a, b) => b.calls - a.calls) - .map((tool) => ({ - name: tool.name, - calls: tool.calls, - succeeded: tool.succeeded, - failed: tool.failed, - unfinished: tool.unfinished, - durationP50: median(tool.durations), - })), } }) function windows(from: number, to: number) { - return Array.from({ length: Math.max(1, Math.ceil((to - from) / Window)) }, (_, index) => ({ + return Array.from({ length: Math.ceil((to - from) / Window) }, (_, index) => ({ from: from + index * Window, to: Math.min(to, from + (index + 1) * Window), })) } +function batches(ids: string[]) { + return Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) => ids.slice(index * 500, (index + 1) * 500)) +} + function rowTokens(row: MessageRow): Tokens { return { input: row.input ?? 0, diff --git a/packages/core/test/session-stats.test.ts b/packages/core/test/session-stats.test.ts index f8b2fc3c272..95604e161b7 100644 --- a/packages/core/test/session-stats.test.ts +++ b/packages/core/test/session-stats.test.ts @@ -20,9 +20,12 @@ import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(Database.node)) const projectID = Project.ID.make("stats-project") +const otherProjectID = Project.ID.make("stats-other-project") const sessionID = Session.ID.make("ses_stats_root") const childID = Session.ID.make("ses_stats_child") const forkID = Session.ID.make("ses_stats_fork") +const usageOnlyID = Session.ID.make("ses_stats_usage_only") +const otherSessionID = Session.ID.make("ses_stats_other") const encodeMessage = Schema.encodeSync(SessionMessage.Info) const encodeUsage = Schema.encodeSync(SessionEvent.UsageRecorded.data) @@ -32,7 +35,10 @@ describe("SessionStats", () => { const db = (yield* Database.Service).db yield* db .insert(ProjectTable) - .values({ id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] }) + .values([ + { id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] }, + { id: otherProjectID, worktree: AbsolutePath.make("/other"), name: "other", sandboxes: [] }, + ]) .run() .pipe(Effect.orDie) yield* db @@ -56,6 +62,8 @@ describe("SessionStats", () => { version: "test", time_created: Date.UTC(2026, 0, 4), }, + { id: usageOnlyID, project_id: projectID, slug: "usage", directory: "/stats", version: "test" }, + { id: otherSessionID, project_id: otherProjectID, slug: "other", directory: "/other", version: "test" }, ]) .run() .pipe(Effect.orDie) @@ -140,6 +148,8 @@ describe("SessionStats", () => { ), messageRow(forkID, 3, assistant("msg_stats_fork_new", Date.UTC(2026, 0, 5, 10), [], "fork-new")), messageRow(sessionID, 3, assistant("msg_stats_outside", Date.UTC(2025, 11, 31, 10), [])), + messageRow(usageOnlyID, 1, assistant("msg_stats_usage_only", Date.UTC(2025, 11, 30, 10), [])), + messageRow(otherSessionID, 1, assistant("msg_stats_other", Date.UTC(2020, 0, 1, 10), [])), ]) .run() .pipe(Effect.orDie) @@ -148,24 +158,40 @@ describe("SessionStats", () => { .values([ { aggregate_id: sessionID, seq: 0 }, { aggregate_id: childID, seq: 0 }, + { aggregate_id: usageOnlyID, seq: 0 }, ]) .run() .pipe(Effect.orDie) yield* db .insert(EventTable) - .values({ - id: Event.ID.make("evt_stats_usage"), - aggregate_id: sessionID, - seq: 0, - created: Date.UTC(2026, 0, 2, 10, 0, 3), - type: SessionEvent.UsageRecorded.type, - data: encodeUsage({ - sessionID, - source: "title", - cost: Money.USD.make(0.5), - tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } }, - }), - }) + .values([ + { + id: Event.ID.make("evt_stats_usage"), + aggregate_id: sessionID, + seq: 0, + created: Date.UTC(2026, 0, 2, 10, 0, 3), + type: SessionEvent.UsageRecorded.type, + data: encodeUsage({ + sessionID, + source: "title", + cost: Money.USD.make(0.5), + tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } }, + }), + }, + { + id: Event.ID.make("evt_stats_usage_boundary"), + aggregate_id: usageOnlyID, + seq: 0, + created: Date.UTC(2026, 0, 2, 10, 0, 3), + type: SessionEvent.UsageRecorded.type, + data: encodeUsage({ + sessionID: usageOnlyID, + source: "compaction", + cost: Money.USD.make(0.25), + tokens: { input: 2, output: 2, reasoning: 2, cache: { read: 2, write: 2 } }, + }), + }, + ]) .run() .pipe(Effect.orDie) @@ -173,17 +199,19 @@ describe("SessionStats", () => { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 1, 1), timezone: "UTC", - models: true, - tools: true, + tools: "detail", }) expect(stats.sessions).toBe(2) expect(stats.subagents).toBe(1) expect(stats.prompts).toBe(1) expect(stats.steps).toBe(3) - expect(stats.tokens).toEqual({ input: 41, output: 21, reasoning: 9, cache: { read: 17, write: 5 } }) - expect(stats.cost).toBe(Money.USD.make(6.5)) - expect(stats.tools).toEqual({ calls: 2, succeeded: 1, failed: 1, unfinished: 0 }) + expect(stats.tokens).toEqual({ input: 42, output: 22, reasoning: 10, cache: { read: 18, write: 6 } }) + expect(stats.cost).toBe(Money.USD.make(6.25)) + expect(stats.tools).toMatchObject({ + mode: "detail", + totals: { calls: 2, succeeded: 1, failed: 1, unfinished: 0 }, + }) expect(stats.activity).toEqual([ { date: "2026-01-02", steps: 1 }, { date: "2026-01-03", steps: 1 }, @@ -191,7 +219,9 @@ describe("SessionStats", () => { ]) expect(stats.streak).toBe(2) expect(stats.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"]) - expect(stats.toolUsage).toMatchObject([ + expect(stats.tools.mode).toBe("detail") + if (stats.tools.mode !== "detail") throw new Error("Expected detailed tool statistics") + expect(stats.tools.usage).toMatchObject([ { name: "read", calls: 1, succeeded: 1, failed: 0, durationP50: 250 }, { name: "edit", calls: 1, succeeded: 0, failed: 1, durationP50: 2_000 }, ]) @@ -200,11 +230,33 @@ describe("SessionStats", () => { from: Date.UTC(2026, 0, 1), to: Date.UTC(2026, 1, 1), timezone: "UTC", - toolSummary: true, }) - expect(summary.models).toEqual([]) - expect(summary.toolUsage).toEqual([]) - expect(summary.tools).toEqual({ calls: 2, succeeded: 1, failed: 1, unfinished: 0 }) + expect(summary.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"]) + expect(summary.tools).toEqual({ + mode: "summary", + totals: { calls: 2, succeeded: 1, failed: 1, unfinished: 0 }, + }) + + const withoutTools = yield* SessionStats.get({ + from: Date.UTC(2026, 0, 1), + to: Date.UTC(2026, 1, 1), + timezone: "UTC", + tools: "none", + }) + expect(withoutTools.tools).toEqual({ mode: "none" }) + + const project = yield* SessionStats.get({ projectID, timezone: "UTC", tools: "none" }) + expect(DateTime.toEpochMillis(project.range.from)).toBe(Date.UTC(2025, 11, 30, 10)) + + const error = yield* Effect.flip( + SessionStats.get({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1), tools: "none" }), + ) + expect(error).toEqual( + new SessionStats.InvalidRangeError({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1) }), + ) + + const future = yield* Effect.flip(SessionStats.get({ from: Number.MAX_SAFE_INTEGER, tools: "none" })) + expect(future._tag).toBe("SessionStats.InvalidRangeError") }), ) }) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 71c7593f808..9a073b2d8c2 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -865,22 +865,6 @@ }, "required": false }, - { - "name": "models", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, { "name": "tools", "in": "query", @@ -888,23 +872,7 @@ "anyOf": [ { "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, - { - "name": "toolSummary", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] + "enum": ["none", "summary", "detail"] }, { "type": "null" @@ -16647,27 +16615,7 @@ "$ref": "#/components/schemas/Money.USD" }, "tools": { - "type": "object", - "properties": { - "calls": { - "type": "integer", - "minimum": 0 - }, - "succeeded": { - "type": "integer", - "minimum": 0 - }, - "failed": { - "type": "integer", - "minimum": 0 - }, - "unfinished": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["calls", "succeeded", "failed", "unfinished"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionStats.Tools" }, "activeDays": { "type": "integer", @@ -16688,12 +16636,6 @@ "items": { "$ref": "#/components/schemas/SessionStats.ModelUsage" } - }, - "toolUsage": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionStats.ToolUsage" - } } }, "required": [ @@ -16708,8 +16650,7 @@ "activeDays", "streak", "activity", - "models", - "toolUsage" + "models" ], "additionalProperties": false }, @@ -16733,6 +16674,29 @@ "required": ["model", "steps", "tokens", "cost"], "additionalProperties": false }, + "SessionStats.ToolTotals": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "minimum": 0 + }, + "succeeded": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "unfinished": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["calls", "succeeded", "failed", "unfinished"], + "additionalProperties": false + }, "SessionStats.ToolUsage": { "type": "object", "properties": { @@ -16762,6 +16726,55 @@ "required": ["name", "calls", "succeeded", "failed", "unfinished"], "additionalProperties": false }, + "SessionStats.Tools": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["none"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["summary"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + } + }, + "required": ["mode", "totals"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["detail"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + }, + "usage": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.ToolUsage" + } + } + }, + "required": ["mode", "totals", "usage"], + "additionalProperties": false + } + ] + }, "SessionTransfer.Data": { "type": "object", "properties": { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index e5291fae6a6..3a41b185ed2 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -154,9 +154,7 @@ export const makeSessionGroup = (sessionLo to: Schema.NumberFromString.pipe(Schema.optional), project: Project.ID.pipe(Schema.optional), timezone: Schema.String.pipe(Schema.optional), - models: BooleanFromString.pipe(Schema.optional), - tools: BooleanFromString.pipe(Schema.optional), - toolSummary: BooleanFromString.pipe(Schema.optional), + tools: SessionStats.ToolMode.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionStats.Info }), error: InvalidRequestError, diff --git a/packages/schema/src/session-stats.ts b/packages/schema/src/session-stats.ts index 532e59d78c3..1af3c0f889e 100644 --- a/packages/schema/src/session-stats.ts +++ b/packages/schema/src/session-stats.ts @@ -30,6 +30,24 @@ export const ToolUsage = Schema.Struct({ }).annotate({ identifier: "SessionStats.ToolUsage" }) export type ToolUsage = typeof ToolUsage.Type +export const ToolMode = Schema.Literals(["none", "summary", "detail"]) +export type ToolMode = typeof ToolMode.Type + +export const ToolTotals = Schema.Struct({ + calls: NonNegativeInt, + succeeded: NonNegativeInt, + failed: NonNegativeInt, + unfinished: NonNegativeInt, +}).annotate({ identifier: "SessionStats.ToolTotals" }) +export type ToolTotals = typeof ToolTotals.Type + +export const Tools = Schema.Union([ + Schema.Struct({ mode: Schema.Literal("none") }), + Schema.Struct({ mode: Schema.Literal("summary"), totals: ToolTotals }), + Schema.Struct({ mode: Schema.Literal("detail"), totals: ToolTotals, usage: Schema.Array(ToolUsage) }), +]).annotate({ identifier: "SessionStats.Tools" }) +export type Tools = typeof Tools.Type + export const Info = Schema.Struct({ range: Schema.Struct({ from: DateTimeUtcFromMillis, @@ -41,16 +59,10 @@ export const Info = Schema.Struct({ steps: NonNegativeInt, tokens: TokenUsage.Info, cost: Money.USD, - tools: Schema.Struct({ - calls: NonNegativeInt, - succeeded: NonNegativeInt, - failed: NonNegativeInt, - unfinished: NonNegativeInt, - }), + tools: Tools, activeDays: NonNegativeInt, streak: NonNegativeInt, activity: Schema.Array(Activity), models: Schema.Array(ModelUsage), - toolUsage: Schema.Array(ToolUsage), }).annotate({ identifier: "SessionStats.Info" }) export type Info = typeof Info.Type diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 49fb9f932a5..346468eefa3 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -90,8 +90,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.stats", Effect.fn(function* (ctx) { - if (ctx.query.from !== undefined && ctx.query.to !== undefined && ctx.query.from >= ctx.query.to) - return yield* new InvalidRequestError({ message: "Stats range must end after it starts" }) const timezone = ctx.query.timezone ?? "UTC" yield* Effect.try({ try: () => new Intl.DateTimeFormat("en-US", { timeZone: timezone }), @@ -103,10 +101,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl to: ctx.query.to, projectID: ctx.query.project, timezone, - models: ctx.query.models, tools: ctx.query.tools, - toolSummary: ctx.query.toolSummary, - }), + }).pipe( + Effect.mapError(() => new InvalidRequestError({ message: "Stats range must end after it starts" })), + ), } }), ) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 71c7593f808..9a073b2d8c2 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -865,22 +865,6 @@ }, "required": false }, - { - "name": "models", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, { "name": "tools", "in": "query", @@ -888,23 +872,7 @@ "anyOf": [ { "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, - { - "name": "toolSummary", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] + "enum": ["none", "summary", "detail"] }, { "type": "null" @@ -16647,27 +16615,7 @@ "$ref": "#/components/schemas/Money.USD" }, "tools": { - "type": "object", - "properties": { - "calls": { - "type": "integer", - "minimum": 0 - }, - "succeeded": { - "type": "integer", - "minimum": 0 - }, - "failed": { - "type": "integer", - "minimum": 0 - }, - "unfinished": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["calls", "succeeded", "failed", "unfinished"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionStats.Tools" }, "activeDays": { "type": "integer", @@ -16688,12 +16636,6 @@ "items": { "$ref": "#/components/schemas/SessionStats.ModelUsage" } - }, - "toolUsage": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionStats.ToolUsage" - } } }, "required": [ @@ -16708,8 +16650,7 @@ "activeDays", "streak", "activity", - "models", - "toolUsage" + "models" ], "additionalProperties": false }, @@ -16733,6 +16674,29 @@ "required": ["model", "steps", "tokens", "cost"], "additionalProperties": false }, + "SessionStats.ToolTotals": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "minimum": 0 + }, + "succeeded": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "unfinished": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["calls", "succeeded", "failed", "unfinished"], + "additionalProperties": false + }, "SessionStats.ToolUsage": { "type": "object", "properties": { @@ -16762,6 +16726,55 @@ "required": ["name", "calls", "succeeded", "failed", "unfinished"], "additionalProperties": false }, + "SessionStats.Tools": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["none"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["summary"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + } + }, + "required": ["mode", "totals"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["detail"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + }, + "usage": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.ToolUsage" + } + } + }, + "required": ["mode", "totals", "usage"], + "additionalProperties": false + } + ] + }, "SessionTransfer.Data": { "type": "object", "properties": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 71c7593f808..9a073b2d8c2 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -865,22 +865,6 @@ }, "required": false }, - { - "name": "models", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, { "name": "tools", "in": "query", @@ -888,23 +872,7 @@ "anyOf": [ { "type": "string", - "enum": ["true", "false"] - }, - { - "type": "null" - } - ] - }, - "required": false - }, - { - "name": "toolSummary", - "in": "query", - "schema": { - "anyOf": [ - { - "type": "string", - "enum": ["true", "false"] + "enum": ["none", "summary", "detail"] }, { "type": "null" @@ -16647,27 +16615,7 @@ "$ref": "#/components/schemas/Money.USD" }, "tools": { - "type": "object", - "properties": { - "calls": { - "type": "integer", - "minimum": 0 - }, - "succeeded": { - "type": "integer", - "minimum": 0 - }, - "failed": { - "type": "integer", - "minimum": 0 - }, - "unfinished": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["calls", "succeeded", "failed", "unfinished"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionStats.Tools" }, "activeDays": { "type": "integer", @@ -16688,12 +16636,6 @@ "items": { "$ref": "#/components/schemas/SessionStats.ModelUsage" } - }, - "toolUsage": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SessionStats.ToolUsage" - } } }, "required": [ @@ -16708,8 +16650,7 @@ "activeDays", "streak", "activity", - "models", - "toolUsage" + "models" ], "additionalProperties": false }, @@ -16733,6 +16674,29 @@ "required": ["model", "steps", "tokens", "cost"], "additionalProperties": false }, + "SessionStats.ToolTotals": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "minimum": 0 + }, + "succeeded": { + "type": "integer", + "minimum": 0 + }, + "failed": { + "type": "integer", + "minimum": 0 + }, + "unfinished": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["calls", "succeeded", "failed", "unfinished"], + "additionalProperties": false + }, "SessionStats.ToolUsage": { "type": "object", "properties": { @@ -16762,6 +16726,55 @@ "required": ["name", "calls", "succeeded", "failed", "unfinished"], "additionalProperties": false }, + "SessionStats.Tools": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["none"] + } + }, + "required": ["mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["summary"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + } + }, + "required": ["mode", "totals"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["detail"] + }, + "totals": { + "$ref": "#/components/schemas/SessionStats.ToolTotals" + }, + "usage": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionStats.ToolUsage" + } + } + }, + "required": ["mode", "totals", "usage"], + "additionalProperties": false + } + ] + }, "SessionTransfer.Data": { "type": "object", "properties": {