stats.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import type { Argv } from "yargs"
  2. import { cmd } from "./cmd"
  3. import { Session } from "../../session"
  4. import { bootstrap } from "../bootstrap"
  5. import { Storage } from "../../storage/storage"
  6. import { Project } from "../../project/project"
  7. import { Instance } from "../../project/instance"
  8. interface SessionStats {
  9. totalSessions: number
  10. totalMessages: number
  11. totalCost: number
  12. totalTokens: {
  13. input: number
  14. output: number
  15. reasoning: number
  16. cache: {
  17. read: number
  18. write: number
  19. }
  20. }
  21. toolUsage: Record<string, number>
  22. modelUsage: Record<
  23. string,
  24. {
  25. messages: number
  26. tokens: {
  27. input: number
  28. output: number
  29. }
  30. cost: number
  31. }
  32. >
  33. dateRange: {
  34. earliest: number
  35. latest: number
  36. }
  37. days: number
  38. costPerDay: number
  39. tokensPerSession: number
  40. medianTokensPerSession: number
  41. }
  42. export const StatsCommand = cmd({
  43. command: "stats",
  44. describe: "show token usage and cost statistics",
  45. builder: (yargs: Argv) => {
  46. return yargs
  47. .option("days", {
  48. describe: "show stats for the last N days (default: all time)",
  49. type: "number",
  50. })
  51. .option("tools", {
  52. describe: "number of tools to show (default: all)",
  53. type: "number",
  54. })
  55. .option("models", {
  56. describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
  57. })
  58. .option("project", {
  59. describe: "filter by project (default: all projects, empty string: current project)",
  60. type: "string",
  61. })
  62. },
  63. handler: async (args) => {
  64. await bootstrap(process.cwd(), async () => {
  65. const stats = await aggregateSessionStats(args.days, args.project)
  66. let modelLimit: number | undefined
  67. if (args.models === true) {
  68. modelLimit = Infinity
  69. } else if (typeof args.models === "number") {
  70. modelLimit = args.models
  71. }
  72. displayStats(stats, args.tools, modelLimit)
  73. })
  74. },
  75. })
  76. async function getCurrentProject(): Promise<Project.Info> {
  77. return Instance.project
  78. }
  79. async function getAllSessions(): Promise<Session.Info[]> {
  80. const sessions: Session.Info[] = []
  81. const projectKeys = await Storage.list(["project"])
  82. const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
  83. for (const project of projects) {
  84. if (!project) continue
  85. const sessionKeys = await Storage.list(["session", project.id])
  86. const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key)))
  87. for (const session of projectSessions) {
  88. if (session) {
  89. sessions.push(session)
  90. }
  91. }
  92. }
  93. return sessions
  94. }
  95. export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
  96. const sessions = await getAllSessions()
  97. const MS_IN_DAY = 24 * 60 * 60 * 1000
  98. const cutoffTime = (() => {
  99. if (days === undefined) return 0
  100. if (days === 0) {
  101. const now = new Date()
  102. now.setHours(0, 0, 0, 0)
  103. return now.getTime()
  104. }
  105. return Date.now() - days * MS_IN_DAY
  106. })()
  107. const windowDays = (() => {
  108. if (days === undefined) return
  109. if (days === 0) return 1
  110. return days
  111. })()
  112. let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
  113. if (projectFilter !== undefined) {
  114. if (projectFilter === "") {
  115. const currentProject = await getCurrentProject()
  116. filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
  117. } else {
  118. filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
  119. }
  120. }
  121. const stats: SessionStats = {
  122. totalSessions: filteredSessions.length,
  123. totalMessages: 0,
  124. totalCost: 0,
  125. totalTokens: {
  126. input: 0,
  127. output: 0,
  128. reasoning: 0,
  129. cache: {
  130. read: 0,
  131. write: 0,
  132. },
  133. },
  134. toolUsage: {},
  135. modelUsage: {},
  136. dateRange: {
  137. earliest: Date.now(),
  138. latest: Date.now(),
  139. },
  140. days: 0,
  141. costPerDay: 0,
  142. tokensPerSession: 0,
  143. medianTokensPerSession: 0,
  144. }
  145. if (filteredSessions.length > 1000) {
  146. console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`)
  147. }
  148. if (filteredSessions.length === 0) {
  149. stats.days = windowDays ?? 0
  150. return stats
  151. }
  152. let earliestTime = Date.now()
  153. let latestTime = 0
  154. const sessionTotalTokens: number[] = []
  155. const BATCH_SIZE = 20
  156. for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
  157. const batch = filteredSessions.slice(i, i + BATCH_SIZE)
  158. const batchPromises = batch.map(async (session) => {
  159. const messages = await Session.messages({ sessionID: session.id })
  160. let sessionCost = 0
  161. let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
  162. let sessionToolUsage: Record<string, number> = {}
  163. let sessionModelUsage: Record<
  164. string,
  165. {
  166. messages: number
  167. tokens: {
  168. input: number
  169. output: number
  170. }
  171. cost: number
  172. }
  173. > = {}
  174. for (const message of messages) {
  175. if (message.info.role === "assistant") {
  176. sessionCost += message.info.cost || 0
  177. const modelKey = `${message.info.providerID}/${message.info.modelID}`
  178. if (!sessionModelUsage[modelKey]) {
  179. sessionModelUsage[modelKey] = {
  180. messages: 0,
  181. tokens: { input: 0, output: 0 },
  182. cost: 0,
  183. }
  184. }
  185. sessionModelUsage[modelKey].messages++
  186. sessionModelUsage[modelKey].cost += message.info.cost || 0
  187. if (message.info.tokens) {
  188. sessionTokens.input += message.info.tokens.input || 0
  189. sessionTokens.output += message.info.tokens.output || 0
  190. sessionTokens.reasoning += message.info.tokens.reasoning || 0
  191. sessionTokens.cache.read += message.info.tokens.cache?.read || 0
  192. sessionTokens.cache.write += message.info.tokens.cache?.write || 0
  193. sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
  194. sessionModelUsage[modelKey].tokens.output +=
  195. (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
  196. }
  197. }
  198. for (const part of message.parts) {
  199. if (part.type === "tool" && part.tool) {
  200. sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
  201. }
  202. }
  203. }
  204. return {
  205. messageCount: messages.length,
  206. sessionCost,
  207. sessionTokens,
  208. sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
  209. sessionToolUsage,
  210. sessionModelUsage,
  211. earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created,
  212. latestTime: session.time.updated,
  213. }
  214. })
  215. const batchResults = await Promise.all(batchPromises)
  216. for (const result of batchResults) {
  217. earliestTime = Math.min(earliestTime, result.earliestTime)
  218. latestTime = Math.max(latestTime, result.latestTime)
  219. sessionTotalTokens.push(result.sessionTotalTokens)
  220. stats.totalMessages += result.messageCount
  221. stats.totalCost += result.sessionCost
  222. stats.totalTokens.input += result.sessionTokens.input
  223. stats.totalTokens.output += result.sessionTokens.output
  224. stats.totalTokens.reasoning += result.sessionTokens.reasoning
  225. stats.totalTokens.cache.read += result.sessionTokens.cache.read
  226. stats.totalTokens.cache.write += result.sessionTokens.cache.write
  227. for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
  228. stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
  229. }
  230. for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
  231. if (!stats.modelUsage[model]) {
  232. stats.modelUsage[model] = {
  233. messages: 0,
  234. tokens: { input: 0, output: 0 },
  235. cost: 0,
  236. }
  237. }
  238. stats.modelUsage[model].messages += usage.messages
  239. stats.modelUsage[model].tokens.input += usage.tokens.input
  240. stats.modelUsage[model].tokens.output += usage.tokens.output
  241. stats.modelUsage[model].cost += usage.cost
  242. }
  243. }
  244. }
  245. const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
  246. const effectiveDays = windowDays ?? rangeDays
  247. stats.dateRange = {
  248. earliest: earliestTime,
  249. latest: latestTime,
  250. }
  251. stats.days = effectiveDays
  252. stats.costPerDay = stats.totalCost / effectiveDays
  253. const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
  254. stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
  255. sessionTotalTokens.sort((a, b) => a - b)
  256. const mid = Math.floor(sessionTotalTokens.length / 2)
  257. stats.medianTokensPerSession =
  258. sessionTotalTokens.length === 0
  259. ? 0
  260. : sessionTotalTokens.length % 2 === 0
  261. ? (sessionTotalTokens[mid - 1] + sessionTotalTokens[mid]) / 2
  262. : sessionTotalTokens[mid]
  263. return stats
  264. }
  265. export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
  266. const width = 56
  267. function renderRow(label: string, value: string): string {
  268. const availableWidth = width - 1
  269. const paddingNeeded = availableWidth - label.length - value.length
  270. const padding = Math.max(0, paddingNeeded)
  271. return `│${label}${" ".repeat(padding)}${value} │`
  272. }
  273. // Overview section
  274. console.log("┌────────────────────────────────────────────────────────┐")
  275. console.log("│ OVERVIEW │")
  276. console.log("├────────────────────────────────────────────────────────┤")
  277. console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
  278. console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
  279. console.log(renderRow("Days", stats.days.toString()))
  280. console.log("└────────────────────────────────────────────────────────┘")
  281. console.log()
  282. // Cost & Tokens section
  283. console.log("┌────────────────────────────────────────────────────────┐")
  284. console.log("│ COST & TOKENS │")
  285. console.log("├────────────────────────────────────────────────────────┤")
  286. const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
  287. const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
  288. const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession
  289. console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
  290. console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`))
  291. console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession))))
  292. const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession
  293. console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession))))
  294. console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
  295. console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
  296. console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
  297. console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
  298. console.log("└────────────────────────────────────────────────────────┘")
  299. console.log()
  300. // Model Usage section
  301. if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
  302. const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
  303. const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
  304. console.log("┌────────────────────────────────────────────────────────┐")
  305. console.log("│ MODEL USAGE │")
  306. console.log("├────────────────────────────────────────────────────────┤")
  307. for (const [model, usage] of modelsToDisplay) {
  308. console.log(`│ ${model.padEnd(54)} │`)
  309. console.log(renderRow(" Messages", usage.messages.toLocaleString()))
  310. console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
  311. console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
  312. console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
  313. console.log("├────────────────────────────────────────────────────────┤")
  314. }
  315. // Remove last separator and add bottom border
  316. process.stdout.write("\x1B[1A") // Move up one line
  317. console.log("└────────────────────────────────────────────────────────┘")
  318. }
  319. console.log()
  320. // Tool Usage section
  321. if (Object.keys(stats.toolUsage).length > 0) {
  322. const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
  323. const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
  324. console.log("┌────────────────────────────────────────────────────────┐")
  325. console.log("│ TOOL USAGE │")
  326. console.log("├────────────────────────────────────────────────────────┤")
  327. const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
  328. const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
  329. for (const [tool, count] of toolsToDisplay) {
  330. const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
  331. const bar = "█".repeat(barLength)
  332. const percentage = ((count / totalToolUsage) * 100).toFixed(1)
  333. const maxToolLength = 18
  334. const truncatedTool = tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
  335. const toolName = truncatedTool.padEnd(maxToolLength)
  336. const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
  337. const padding = Math.max(0, width - content.length - 1)
  338. console.log(`│${content}${" ".repeat(padding)} │`)
  339. }
  340. console.log("└────────────────────────────────────────────────────────┘")
  341. }
  342. console.log()
  343. }
  344. function formatNumber(num: number): string {
  345. if (num >= 1000000) {
  346. return (num / 1000000).toFixed(1) + "M"
  347. } else if (num >= 1000) {
  348. return (num / 1000).toFixed(1) + "K"
  349. }
  350. return num.toString()
  351. }