stats.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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. dateRange: {
  23. earliest: number
  24. latest: number
  25. }
  26. days: number
  27. costPerDay: number
  28. tokensPerSession: number
  29. medianTokensPerSession: number
  30. }
  31. export const StatsCommand = cmd({
  32. command: "stats",
  33. describe: "show token usage and cost statistics",
  34. builder: (yargs: Argv) => {
  35. return yargs
  36. .option("days", {
  37. describe: "show stats for the last N days (default: all time)",
  38. type: "number",
  39. })
  40. .option("tools", {
  41. describe: "number of tools to show (default: all)",
  42. type: "number",
  43. })
  44. .option("project", {
  45. describe: "filter by project (default: all projects, empty string: current project)",
  46. type: "string",
  47. })
  48. },
  49. handler: async (args) => {
  50. await bootstrap(process.cwd(), async () => {
  51. const stats = await aggregateSessionStats(args.days, args.project)
  52. displayStats(stats, args.tools)
  53. })
  54. },
  55. })
  56. async function getCurrentProject(): Promise<Project.Info> {
  57. return Instance.project
  58. }
  59. async function getAllSessions(): Promise<Session.Info[]> {
  60. const sessions: Session.Info[] = []
  61. const projectKeys = await Storage.list(["project"])
  62. const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
  63. for (const project of projects) {
  64. if (!project) continue
  65. const sessionKeys = await Storage.list(["session", project.id])
  66. const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key)))
  67. for (const session of projectSessions) {
  68. if (session) {
  69. sessions.push(session)
  70. }
  71. }
  72. }
  73. return sessions
  74. }
  75. async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
  76. const sessions = await getAllSessions()
  77. const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
  78. const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
  79. let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
  80. if (projectFilter !== undefined) {
  81. if (projectFilter === "") {
  82. const currentProject = await getCurrentProject()
  83. filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
  84. } else {
  85. filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
  86. }
  87. }
  88. const stats: SessionStats = {
  89. totalSessions: filteredSessions.length,
  90. totalMessages: 0,
  91. totalCost: 0,
  92. totalTokens: {
  93. input: 0,
  94. output: 0,
  95. reasoning: 0,
  96. cache: {
  97. read: 0,
  98. write: 0,
  99. },
  100. },
  101. toolUsage: {},
  102. dateRange: {
  103. earliest: Date.now(),
  104. latest: Date.now(),
  105. },
  106. days: 0,
  107. costPerDay: 0,
  108. tokensPerSession: 0,
  109. medianTokensPerSession: 0,
  110. }
  111. if (filteredSessions.length > 1000) {
  112. console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`)
  113. }
  114. if (filteredSessions.length === 0) {
  115. return stats
  116. }
  117. let earliestTime = Date.now()
  118. let latestTime = 0
  119. const sessionTotalTokens: number[] = []
  120. const BATCH_SIZE = 20
  121. for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
  122. const batch = filteredSessions.slice(i, i + BATCH_SIZE)
  123. const batchPromises = batch.map(async (session) => {
  124. const messages = await Session.messages({ sessionID: session.id })
  125. let sessionCost = 0
  126. let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
  127. let sessionToolUsage: Record<string, number> = {}
  128. for (const message of messages) {
  129. if (message.info.role === "assistant") {
  130. sessionCost += message.info.cost || 0
  131. if (message.info.tokens) {
  132. sessionTokens.input += message.info.tokens.input || 0
  133. sessionTokens.output += message.info.tokens.output || 0
  134. sessionTokens.reasoning += message.info.tokens.reasoning || 0
  135. sessionTokens.cache.read += message.info.tokens.cache?.read || 0
  136. sessionTokens.cache.write += message.info.tokens.cache?.write || 0
  137. }
  138. }
  139. for (const part of message.parts) {
  140. if (part.type === "tool" && part.tool) {
  141. sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
  142. }
  143. }
  144. }
  145. return {
  146. messageCount: messages.length,
  147. sessionCost,
  148. sessionTokens,
  149. sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
  150. sessionToolUsage,
  151. earliestTime: session.time.created,
  152. latestTime: session.time.updated,
  153. }
  154. })
  155. const batchResults = await Promise.all(batchPromises)
  156. for (const result of batchResults) {
  157. earliestTime = Math.min(earliestTime, result.earliestTime)
  158. latestTime = Math.max(latestTime, result.latestTime)
  159. sessionTotalTokens.push(result.sessionTotalTokens)
  160. stats.totalMessages += result.messageCount
  161. stats.totalCost += result.sessionCost
  162. stats.totalTokens.input += result.sessionTokens.input
  163. stats.totalTokens.output += result.sessionTokens.output
  164. stats.totalTokens.reasoning += result.sessionTokens.reasoning
  165. stats.totalTokens.cache.read += result.sessionTokens.cache.read
  166. stats.totalTokens.cache.write += result.sessionTokens.cache.write
  167. for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
  168. stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
  169. }
  170. }
  171. }
  172. const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
  173. stats.dateRange = {
  174. earliest: earliestTime,
  175. latest: latestTime,
  176. }
  177. stats.days = actualDays
  178. stats.costPerDay = stats.totalCost / actualDays
  179. const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
  180. stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
  181. sessionTotalTokens.sort((a, b) => a - b)
  182. const mid = Math.floor(sessionTotalTokens.length / 2)
  183. stats.medianTokensPerSession =
  184. sessionTotalTokens.length === 0
  185. ? 0
  186. : sessionTotalTokens.length % 2 === 0
  187. ? (sessionTotalTokens[mid - 1] + sessionTotalTokens[mid]) / 2
  188. : sessionTotalTokens[mid]
  189. return stats
  190. }
  191. export function displayStats(stats: SessionStats, toolLimit?: number) {
  192. const width = 56
  193. function renderRow(label: string, value: string): string {
  194. const availableWidth = width - 1
  195. const paddingNeeded = availableWidth - label.length - value.length
  196. const padding = Math.max(0, paddingNeeded)
  197. return `│${label}${" ".repeat(padding)}${value} │`
  198. }
  199. // Overview section
  200. console.log("┌────────────────────────────────────────────────────────┐")
  201. console.log("│ OVERVIEW │")
  202. console.log("├────────────────────────────────────────────────────────┤")
  203. console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
  204. console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
  205. console.log(renderRow("Days", stats.days.toString()))
  206. console.log("└────────────────────────────────────────────────────────┘")
  207. console.log()
  208. // Cost & Tokens section
  209. console.log("┌────────────────────────────────────────────────────────┐")
  210. console.log("│ COST & TOKENS │")
  211. console.log("├────────────────────────────────────────────────────────┤")
  212. const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
  213. const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
  214. const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession
  215. console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
  216. console.log(renderRow("Avg Cost/Day", `$${costPerDay.toFixed(2)}`))
  217. console.log(renderRow("Avg Tokens/Session", formatNumber(Math.round(tokensPerSession))))
  218. const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession
  219. console.log(renderRow("Median Tokens/Session", formatNumber(Math.round(medianTokensPerSession))))
  220. console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
  221. console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
  222. console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
  223. console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
  224. console.log("└────────────────────────────────────────────────────────┘")
  225. console.log()
  226. // Tool Usage section
  227. if (Object.keys(stats.toolUsage).length > 0) {
  228. const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
  229. const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
  230. console.log("┌────────────────────────────────────────────────────────┐")
  231. console.log("│ TOOL USAGE │")
  232. console.log("├────────────────────────────────────────────────────────┤")
  233. const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
  234. const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
  235. for (const [tool, count] of toolsToDisplay) {
  236. const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
  237. const bar = "█".repeat(barLength)
  238. const percentage = ((count / totalToolUsage) * 100).toFixed(1)
  239. const maxToolLength = 18
  240. const truncatedTool = tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
  241. const toolName = truncatedTool.padEnd(maxToolLength)
  242. const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
  243. const padding = Math.max(0, width - content.length - 1)
  244. console.log(`│${content}${" ".repeat(padding)} │`)
  245. }
  246. console.log("└────────────────────────────────────────────────────────┘")
  247. }
  248. console.log()
  249. }
  250. function formatNumber(num: number): string {
  251. if (num >= 1000000) {
  252. return (num / 1000000).toFixed(1) + "M"
  253. } else if (num >= 1000) {
  254. return (num / 1000).toFixed(1) + "K"
  255. }
  256. return num.toString()
  257. }