feat: implement stats command (#3832)

This commit is contained in:
kaanmertkoc
2025-11-04 03:41:30 +03:00
committed by GitHub
parent 1cf1e88b52
commit 63862b1609

View File

@@ -1,4 +1,10 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Storage } from "../../storage/storage"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
interface SessionStats { interface SessionStats {
totalSessions: number totalSessions: number
@@ -24,10 +30,186 @@ interface SessionStats {
export const StatsCommand = cmd({ export const StatsCommand = cmd({
command: "stats", command: "stats",
handler: async () => {}, describe: "show token usage and cost statistics",
builder: (yargs: Argv) => {
return yargs
.option("days", {
describe: "show stats for the last N days (default: all time)",
type: "number",
})
.option("tools", {
describe: "number of tools to show (default: all)",
type: "number",
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
displayStats(stats, args.tools)
})
},
}) })
export function displayStats(stats: SessionStats) { async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
async function getAllSessions(): Promise<Session.Info[]> {
const sessions: Session.Info[] = []
const projectKeys = await Storage.list(["project"])
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
for (const project of projects) {
if (!project) continue
const sessionKeys = await Storage.list(["session", project.id])
const projectSessions = await Promise.all(
sessionKeys.map((key) => Storage.read<Session.Info>(key)),
)
for (const session of projectSessions) {
if (session) {
sessions.push(session)
}
}
}
return sessions
}
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
let filteredSessions = days
? sessions.filter((session) => session.time.updated >= cutoffTime)
: sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
filteredSessions = filteredSessions.filter(
(session) => session.projectID === currentProject.id,
)
} else {
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
}
}
const stats: SessionStats = {
totalSessions: filteredSessions.length,
totalMessages: 0,
totalCost: 0,
totalTokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
toolUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
},
days: 0,
costPerDay: 0,
}
if (filteredSessions.length > 1000) {
console.log(
`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`,
)
}
if (filteredSessions.length === 0) {
return stats
}
let earliestTime = Date.now()
let latestTime = 0
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages(session.id)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
return {
messageCount: messages.length,
sessionCost,
sessionTokens,
sessionToolUsage,
earliestTime: session.time.created,
latestTime: session.time.updated,
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
stats.totalMessages += result.messageCount
stats.totalCost += result.sessionCost
stats.totalTokens.input += result.sessionTokens.input
stats.totalTokens.output += result.sessionTokens.output
stats.totalTokens.reasoning += result.sessionTokens.reasoning
stats.totalTokens.cache.read += result.sessionTokens.cache.read
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
}
}
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
stats.dateRange = {
earliest: earliestTime,
latest: latestTime,
}
stats.days = actualDays
stats.costPerDay = stats.totalCost / actualDays
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number) {
const width = 56 const width = 56
function renderRow(label: string, value: string): string { function renderRow(label: string, value: string): string {
@@ -64,30 +246,35 @@ export function displayStats(stats: SessionStats) {
// Tool Usage section // Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) { if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage) const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
.sort(([, a], [, b]) => b - a) const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
.slice(0, 10)
console.log("┌────────────────────────────────────────────────────────┐") console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │") console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤") console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...sortedTools.map(([, count]) => count)) const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0) const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, count] of sortedTools) { for (const [tool, count] of toolsToDisplay) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20)) const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength) const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1) const percentage = ((count / totalToolUsage) * 100).toFixed(1)
const content = ` ${tool.padEnd(10)} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)` const maxToolLength = 18
const padding = Math.max(0, width - content.length) const truncatedTool =
tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
const toolName = truncatedTool.padEnd(maxToolLength)
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
const padding = Math.max(0, width - content.length - 1)
console.log(`${content}${" ".repeat(padding)}`) console.log(`${content}${" ".repeat(padding)}`)
} }
console.log("└────────────────────────────────────────────────────────┘") console.log("└────────────────────────────────────────────────────────┘")
} }
console.log() console.log()
} }
function formatNumber(num: number): string { function formatNumber(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M" return (num / 1000000).toFixed(1) + "M"