Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437export * as SessionStats from "./stats.js"
import { DateTime, Effect, Option, Schema } from "effect"import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"import { Model } from "@opencode/schema/model"import { Money } from "@opencode/schema/money"import { Project } from "@opencode/schema/project"import { Provider } from "@opencode/schema/provider"import { Event } from "@opencode/schema/event"import { SessionEvent } from "@opencode/schema/session-event"import { ToolMode } from "@opencode/schema/session-stats"import { TokenUsage } from "@opencode/schema/token-usage"import { Database } from "../database/database.js"import { EventTable } from "../event/sql.js"import { SessionMessageTable, SessionTable } from "./sql.js"
type Input = { readonly from?: number readonly to?: number readonly projectID?: Project.ID readonly timezone?: string readonly tools?: ToolMode}
type Tokens = { input: number output: number reasoning: number cache: { read: number; write: number }}
type MessageRow = { sessionID: string parentID: string | null type: "user" | "assistant" timeCreated: number providerID: string | null modelID: string | null variant: string | null input: number | null output: number | null reasoning: number | null cacheRead: number | null cacheWrite: number | null cost: number | null}
type ToolRow = { name: string | null status: string | null duration: number | null}
type ToolSummaryRow = { calls: number succeeded: number failed: number unfinished: number}
type ModelAggregate = { model: Model.Ref steps: number tokens: Tokens cost: number}
type ToolAggregate = { name: string calls: number succeeded: number failed: number unfinished: number durations: number[]}
const decodeUsage = Schema.decodeUnknownOption(SessionEvent.UsageRecorded.data)const Window = 31 * 24 * 60 * 60 * 1_000
export class InvalidRangeError extends Schema.TaggedError<InvalidRangeError>()("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 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(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(from, to) const toolMode = input.tools ?? "summary" const sessions = new Set<string>() const subagents = new Set<string>() const activity = new Map<string, number>() const models = new Map<string, ModelAggregate>() const tools = new Map<string, ToolAggregate>() const totals = { prompts: 0, steps: 0, tokens: emptyTokens(), cost: 0, } const toolTotals = { calls: 0, succeeded: 0, failed: 0, unfinished: 0 } const dateKey = makeDateKey(input.timezone)
yield* Effect.forEach( ranges, (range) => db .all<MessageRow>( sql` SELECT message.session_id AS sessionID, session.parent_id AS parentID, message.type AS type, message.time_created AS timeCreated, json_extract(message.data, '$.model.providerID') AS providerID, json_extract(message.data, '$.model.id') AS modelID, json_extract(message.data, '$.model.variant') AS variant, json_extract(message.data, '$.tokens.input') AS input, json_extract(message.data, '$.tokens.output') AS output, json_extract(message.data, '$.tokens.reasoning') AS reasoning, json_extract(message.data, '$.tokens.cache.read') AS cacheRead, json_extract(message.data, '$.tokens.cache.write') AS cacheWrite, json_extract(message.data, '$.cost') AS cost FROM ${SessionMessageTable} AS message JOIN ${SessionTable} AS session ON session.id = message.session_id WHERE message.type IN ('user', 'assistant') AND message.time_created >= ${range.from} AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) ${project} `, ) .pipe( Effect.orDie, Effect.tap((rows) => Effect.sync(() => { rows.forEach((row) => { if (row.parentID === null) sessions.add(row.sessionID) else subagents.add(row.sessionID) if (row.type === "user") { if (row.parentID === null) totals.prompts++ return }
totals.steps++ const tokens = rowTokens(row) addTokens(totals.tokens, tokens) totals.cost += row.cost ?? 0 const day = dateKey(row.timeCreated) activity.set(day, (activity.get(day) ?? 0) + 1) if (!row.providerID || !row.modelID) return const key = `${row.providerID}/${row.modelID}#${row.variant ?? ""}` const model = models.get(key) ?? { model: { providerID: Provider.ID.make(row.providerID), id: Model.ID.make(row.modelID), variant: row.variant ? Model.VariantID.make(row.variant) : undefined, }, steps: 0, tokens: emptyTokens(), cost: 0, } models.set(key, model) model.steps++ model.cost += row.cost ?? 0 addTokens(model.tokens, tokens) }) }), ), ), { concurrency: 1, discard: true }, )
if (toolMode !== "none") yield* Effect.forEach( ranges, (range) => { if (toolMode === "summary") return db .get<ToolSummaryRow>( sql` WITH calls AS MATERIALIZED ( SELECT json_extract(content.value, '$.state.status') AS status FROM ${SessionMessageTable} AS message JOIN ${SessionTable} AS session ON session.id = message.session_id, json_each(message.data, '$.content') AS content WHERE message.type = 'assistant' AND message.time_created >= ${range.from} AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) AND json_extract(content.value, '$.type') = 'tool' ${project} ) SELECT count(*) AS calls, count(*) FILTER (WHERE status = 'completed') AS succeeded, count(*) FILTER (WHERE status = 'error') AS failed, count(*) FILTER (WHERE status IS NULL OR status NOT IN ('completed', 'error')) AS unfinished FROM calls `, ) .pipe( Effect.orDie, Effect.tap((row) => Effect.sync(() => { if (!row) return toolTotals.calls += row.calls toolTotals.succeeded += row.succeeded toolTotals.failed += row.failed toolTotals.unfinished += row.unfinished }), ), Effect.asVoid, ) return db .all<ToolRow>( sql` SELECT json_extract(content.value, '$.name') AS name, json_extract(content.value, '$.state.status') AS status, CASE WHEN json_extract(content.value, '$.time.completed') IS NULL THEN NULL ELSE json_extract(content.value, '$.time.completed') - coalesce(json_extract(content.value, '$.time.ran'), json_extract(content.value, '$.time.created')) END AS duration FROM ${SessionMessageTable} AS message JOIN ${SessionTable} AS session ON session.id = message.session_id, json_each(message.data, '$.content') AS content WHERE message.type = 'assistant' AND message.time_created >= ${range.from} AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) AND json_extract(content.value, '$.type') = 'tool' ${project} `, ) .pipe( Effect.orDie, Effect.tap((rows) => Effect.sync(() => { rows.forEach((row) => { addToolStatus(toolTotals, row.status) if (!row.name) return const tool = tools.get(row.name) ?? { name: row.name, calls: 0, succeeded: 0, failed: 0, unfinished: 0, durations: [], } tools.set(row.name, tool) addToolStatus(tool, row.status) if (row.duration !== null) tool.durations.push(row.duration) }) }), ), Effect.asVoid, ) }, { concurrency: 1, discard: true }, )
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( batches(ids.map((row) => row.id)), (batch) => db .select({ data: EventTable.data }) .from(EventTable) .where( and( inArray(EventTable.aggregate_id, batch), eq( EventTable.type, Event.versionedType(SessionEvent.UsageRecorded.type, SessionEvent.UsageRecorded.durable.version), ), sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`, gte(EventTable.created, from), lt(EventTable.created, to), ), ) .all() .pipe(Effect.orDie), { concurrency: 4 }, )).flat() events.forEach((row) => { const decoded = decodeUsage(row.data) if (Option.isNone(decoded)) return addTokens(totals.tokens, decoded.value.tokens) totals.cost += decoded.value.cost })
const days = [...activity.entries()].sort(([a], [b]) => a.localeCompare(b)) return { 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: 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) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens)) .map((model) => ({ ...model, cost: Money.USD.make(model.cost) })), }})
function windows(from: number, to: number) { 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, output: row.output ?? 0, reasoning: row.reasoning ?? 0, cache: { read: row.cacheRead ?? 0, write: row.cacheWrite ?? 0 }, }}
function emptyTokens(): Tokens { return { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }}
function addTokens(target: Tokens, source: Tokens) { target.input += source.input target.output += source.output target.reasoning += source.reasoning target.cache.read += source.cache.read target.cache.write += source.cache.write}
function addToolStatus( target: { calls: number; succeeded: number; failed: number; unfinished: number }, status: string | null,) { target.calls++ if (status === "completed") { target.succeeded++ return } if (status === "error") { target.failed++ return } target.unfinished++}
function makeDateKey(timezone = "UTC") { const formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", }) return (time: number) => { const parts = Object.fromEntries(formatter.formatToParts(time).map((part) => [part.type, part.value])) return `${parts.year}-${parts.month}-${parts.day}` }}
function longestStreak(days: string[]) { return days.reduce( (result, day, index) => { const previous = days[index - 1] const current = previous && dayOrdinal(day) - dayOrdinal(previous) === 1 ? result.current + 1 : 1 return { current, longest: Math.max(result.longest, current) } }, { current: 0, longest: 0 }, ).longest}
function dayOrdinal(value: string) { const [year, month, day] = value.split("-").map(Number) return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000)}
function median(values: number[]) { if (values.length === 0) return undefined const sorted = values.toSorted((a, b) => a - b) const middle = Math.floor(sorted.length / 2) return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]}