stats.ts 16 KB

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