@goodandready/dsh-clinebot 0.5.33 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,51 +1,72 @@
1
1
  /**
2
- * Persistent JSON Token Analytics Storage (Issue #129)
2
+ * Persistent JSON telemetry storage.
3
3
  *
4
- * Persists token consumption and request telemetry to ~/.dsh/clinebot-stats.json.
5
- * Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
4
+ * Version 2 adds cache buckets, estimated cost, TTFT/latency sums and a bounded
5
+ * per-session dimension. Version 1 files are upgraded on read.
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
12
+ const MAX_SESSIONS = 500
13
+ const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
14
+ const HEATMAP_RETENTION_MS = 95 * 24 * 60 * 60 * 1000
15
+
12
16
  let inMemoryStats = createEmptyStats()
13
17
  let saveTimer = null
14
18
  let activeStatsPath = ''
15
19
 
20
+ function emptyBucket() {
21
+ return {
22
+ requests: 0,
23
+ successful: 0,
24
+ failed: 0,
25
+ rateLimited429: 0,
26
+ promptTokens: 0,
27
+ completionTokens: 0,
28
+ reasoningTokens: 0,
29
+ cacheReadTokens: 0,
30
+ cacheWriteTokens: 0,
31
+ totalTokens: 0,
32
+ costUsd: 0,
33
+ pricedRequests: 0,
34
+ unpricedRequests: 0,
35
+ ttftMsSum: 0,
36
+ latencyMsSum: 0,
37
+ firstAt: 0,
38
+ lastAt: 0,
39
+ model: '',
40
+ accountId: '',
41
+ purpose: '',
42
+ }
43
+ }
44
+
16
45
  /**
17
46
  * Create a fresh, empty stats object.
18
47
  */
19
48
  export function createEmptyStats() {
20
49
  return {
21
- version: 1,
50
+ version: 2,
22
51
  createdAt: Date.now(),
23
52
  updatedAt: Date.now(),
24
- totals: {
25
- requests: 0,
26
- successful: 0,
27
- failed: 0,
28
- rateLimited429: 0,
29
- promptTokens: 0,
30
- completionTokens: 0,
31
- reasoningTokens: 0,
32
- totalTokens: 0
33
- },
53
+ totals: emptyBucket(),
34
54
  byDay: {},
35
55
  byMonth: {},
36
56
  byModel: {},
37
- byAccount: {}
57
+ byAccount: {},
58
+ bySession: {},
59
+ byHour: {},
60
+ byHourModel: {},
38
61
  }
39
62
  }
40
63
 
41
- /**
42
- * Resolve '~' in paths to user's home directory.
43
- */
44
- /**
45
- * Get active .dsh directory, respecting DSH_HOME and HOME environment variables.
46
- *
47
- * @returns {string}
48
- */
64
+ /** Local `YYYY-MM-DD` key, used by the request heatmap. */
65
+ function localDateKey(date) {
66
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
67
+ }
68
+
69
+ /** Get active .dsh directory, respecting DSH_HOME and HOME. */
49
70
  export function getDshHome() {
50
71
  if (process.env.DSH_HOME) return process.env.DSH_HOME
51
72
  const home = process.env.HOME || homedir()
@@ -65,12 +86,8 @@ export function resolvePath(targetPath) {
65
86
  break
66
87
  }
67
88
  }
68
- if (typeof targetPath !== 'string') {
69
- targetPath = ''
70
- }
71
- if (!targetPath) {
72
- return path.join(getDshHome(), 'clinebot-stats.json')
73
- }
89
+ if (typeof targetPath !== 'string') targetPath = ''
90
+ if (!targetPath) return path.join(getDshHome(), 'clinebot-stats.json')
74
91
  if (targetPath.startsWith('~/') || targetPath === '~') {
75
92
  const home = process.env.HOME || homedir()
76
93
  return path.join(home, targetPath.slice(1))
@@ -80,9 +97,7 @@ export function resolvePath(targetPath) {
80
97
 
81
98
  /**
82
99
  * Load stats from disk.
83
- *
84
100
  * @param {string} [customPath]
85
- * @returns {Object} Loaded stats
86
101
  */
87
102
  export function loadStats(customPath) {
88
103
  const filePath = resolvePath(customPath)
@@ -92,20 +107,25 @@ export function loadStats(customPath) {
92
107
  const raw = readFileSync(filePath, 'utf8')
93
108
  const parsed = JSON.parse(raw)
94
109
  if (parsed && typeof parsed === 'object' && parsed.totals) {
110
+ const empty = createEmptyStats()
95
111
  inMemoryStats = {
96
- ...createEmptyStats(),
112
+ ...empty,
97
113
  ...parsed,
98
- totals: { ...createEmptyStats().totals, ...parsed.totals },
114
+ version: 2,
115
+ totals: { ...empty.totals, ...parsed.totals },
99
116
  byDay: parsed.byDay || {},
100
117
  byMonth: parsed.byMonth || {},
101
118
  byModel: parsed.byModel || {},
102
- byAccount: parsed.byAccount || {}
119
+ byAccount: parsed.byAccount || {},
120
+ bySession: parsed.bySession || {},
121
+ byHour: parsed.byHour || {},
122
+ byHourModel: parsed.byHourModel || {},
103
123
  }
124
+ backfillHourlyFromSessions()
104
125
  return inMemoryStats
105
126
  }
106
127
  }
107
- } catch (err) {
108
- // If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
128
+ } catch {
109
129
  try {
110
130
  if (existsSync(filePath)) {
111
131
  const corruptBackup = `${filePath}.corrupt.${Date.now()}`
@@ -113,52 +133,29 @@ export function loadStats(customPath) {
113
133
  const dir = path.dirname(filePath)
114
134
  const base = path.basename(filePath)
115
135
  const prefix = `${base}.corrupt.`
116
- const existing = readdirSync(dir)
117
- .filter((name) => name.startsWith(prefix))
118
- .sort()
136
+ const existing = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort()
119
137
  while (existing.length > 3) {
120
138
  const oldest = existing.shift()
121
- try {
122
- unlinkSync(path.join(dir, oldest))
123
- } catch {
124
- /* ignore deletion failure */
125
- }
139
+ try { unlinkSync(path.join(dir, oldest)) } catch {}
126
140
  }
127
141
  }
128
- } catch {
129
- /* ignore backup failure */
130
- }
142
+ } catch {}
131
143
  }
132
-
133
144
  inMemoryStats = createEmptyStats()
134
145
  return inMemoryStats
135
146
  }
136
147
 
137
- /**
138
- * Switch active stats storage to a new path.
139
- * Flushes pending writes of current storage and loads stats from the new path.
140
- *
141
- * @param {string} newPath
142
- * @returns {string} active stats path
143
- */
148
+ /** Switch active stats storage and load the target file. */
144
149
  export function switchStatsStorage(newPath) {
145
150
  const resolved = resolvePath(newPath)
146
151
  if (resolved && resolved !== activeStatsPath) {
147
- if (activeStatsPath) {
148
- flushStats(activeStatsPath)
149
- }
152
+ if (activeStatsPath) flushStats(activeStatsPath)
150
153
  activeStatsPath = resolved
151
154
  loadStats(resolved)
152
155
  }
153
156
  return activeStatsPath
154
157
  }
155
158
 
156
-
157
- /**
158
- * Flush any debounced pending writes immediately to disk.
159
- *
160
- * @param {string} [customPath]
161
- */
162
159
  export function flushStats(customPath) {
163
160
  if (saveTimer) {
164
161
  clearTimeout(saveTimer)
@@ -167,167 +164,383 @@ export function flushStats(customPath) {
167
164
  saveStatsSync(customPath)
168
165
  }
169
166
 
170
- /**
171
- * Synchronously write stats to disk using atomic rename.
172
- *
173
- * @param {string} [customPath]
174
- */
175
167
  export function saveStatsSync(customPath) {
176
168
  try {
177
169
  const filePath = resolvePath(customPath || activeStatsPath)
178
170
  activeStatsPath = filePath
179
-
180
171
  const dir = path.dirname(filePath)
181
- if (!existsSync(dir)) {
182
- mkdirSync(dir, { recursive: true })
183
- }
184
-
172
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
185
173
  inMemoryStats.updatedAt = Date.now()
186
174
  const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
187
- const data = JSON.stringify(inMemoryStats, null, 2)
188
-
189
- writeFileSync(tempPath, data, 'utf8')
175
+ writeFileSync(tempPath, JSON.stringify(inMemoryStats, null, 2), 'utf8')
190
176
  renameSync(tempPath, filePath)
191
177
  } catch {
192
- // Best-effort disk persistence
178
+ /* best-effort disk persistence */
193
179
  }
194
180
  }
195
181
 
196
- /**
197
- * Queue a debounced write to avoid disk thrashing during rapid streaming tokens.
198
- *
199
- * @param {string} [customPath]
200
- * @param {number} [debounceMs=500]
201
- */
202
182
  function queueSaveStats(customPath, debounceMs = 500) {
203
- if (saveTimer) {
204
- clearTimeout(saveTimer)
205
- }
183
+ if (saveTimer) clearTimeout(saveTimer)
206
184
  saveTimer = setTimeout(() => {
207
185
  saveTimer = null
208
- try {
209
- saveStatsSync(customPath)
210
- } catch {
211
- // Best-effort disk persistence, never crash host process
212
- }
186
+ try { saveStatsSync(customPath) } catch {}
213
187
  }, debounceMs)
214
188
  }
215
189
 
190
+ function addToBucket(bucket, entry) {
191
+ bucket.requests += 1
192
+ if (entry.isError) bucket.failed += 1
193
+ else bucket.successful += 1
194
+ if (entry.is429) bucket.rateLimited429 += 1
195
+ bucket.promptTokens += entry.promptTokens
196
+ bucket.completionTokens += entry.completionTokens
197
+ bucket.reasoningTokens += entry.reasoningTokens
198
+ bucket.cacheReadTokens += entry.cacheReadTokens
199
+ bucket.cacheWriteTokens += entry.cacheWriteTokens
200
+ bucket.totalTokens += entry.totalTokens
201
+ bucket.costUsd += entry.costUsd
202
+ if (entry.priced) bucket.pricedRequests += 1
203
+ else bucket.unpricedRequests += 1
204
+ bucket.ttftMsSum += entry.ttftMs
205
+ bucket.latencyMsSum += entry.latencyMs
206
+ bucket.firstAt = bucket.firstAt || entry.now
207
+ bucket.lastAt = entry.now
208
+ if (entry.model) bucket.model = entry.model
209
+ if (entry.accountId) bucket.accountId = entry.accountId
210
+ if (entry.purpose) bucket.purpose = entry.purpose
211
+ }
212
+
213
+ function incrementBucket(container, key, entry) {
214
+ if (!container[key]) container[key] = emptyBucket()
215
+ addToBucket(container[key], entry)
216
+ }
217
+
218
+ function pruneSessions(now = Date.now()) {
219
+ const sessions = inMemoryStats.bySession
220
+ for (const [id, bucket] of Object.entries(sessions)) {
221
+ if (now - Number(bucket.lastAt || 0) > SESSION_TTL_MS) delete sessions[id]
222
+ }
223
+ const ids = Object.keys(sessions)
224
+ if (ids.length <= MAX_SESSIONS) return
225
+ ids.sort((a, b) => Number(sessions[a].lastAt || 0) - Number(sessions[b].lastAt || 0))
226
+ for (const id of ids.slice(0, ids.length - MAX_SESSIONS)) delete sessions[id]
227
+ }
228
+
229
+ function pruneHours(now = Date.now()) {
230
+ const cutoff = localDateKey(new Date(now - HEATMAP_RETENTION_MS))
231
+ for (const container of [inMemoryStats.byHour, inMemoryStats.byHourModel]) {
232
+ for (const key of Object.keys(container || {})) {
233
+ if (key < cutoff) delete container[key]
234
+ }
235
+ }
236
+ }
237
+
216
238
  /**
217
- * Record a completion event with usage metrics.
218
- *
219
- * @param {Object} params
220
- * @param {string} [params.model]
221
- * @param {string} [params.accountId]
222
- * @param {number} [params.promptTokens=0]
223
- * @param {number} [params.completionTokens=0]
224
- * @param {number} [params.totalTokens=0]
225
- * @param {boolean} [params.isError=false]
226
- * @param {boolean} [params.is429=false]
227
- * @param {string} [params.statsPath]
239
+ * Best-effort first-run backfill: older files predate the hourly buckets, so
240
+ * each persisted session contributes its requests to the hour it last ran.
241
+ * This is approximate by design; it only runs while the hourly map is empty.
242
+ */
243
+ function backfillHourlyFromSessions() {
244
+ if (Object.keys(inMemoryStats.byHour || {}).length > 0) return
245
+ for (const bucket of Object.values(inMemoryStats.bySession || {})) {
246
+ const requests = Number(bucket?.requests || 0)
247
+ if (requests <= 0) continue
248
+ const at = Number(bucket.lastAt || bucket.firstAt || 0)
249
+ if (!Number.isFinite(at) || at <= 0) continue
250
+ const local = new Date(at)
251
+ const key = `${localDateKey(local)}T${String(local.getHours()).padStart(2, '0')}`
252
+ if (!inMemoryStats.byHour[key]) inMemoryStats.byHour[key] = emptyBucket()
253
+ const hour = inMemoryStats.byHour[key]
254
+ hour.requests += requests
255
+ hour.successful += Number(bucket.successful || 0)
256
+ hour.failed += Number(bucket.failed || 0)
257
+ hour.rateLimited429 += Number(bucket.rateLimited429 || 0)
258
+ hour.promptTokens += Number(bucket.promptTokens || 0)
259
+ hour.completionTokens += Number(bucket.completionTokens || 0)
260
+ hour.reasoningTokens += Number(bucket.reasoningTokens || 0)
261
+ hour.cacheReadTokens += Number(bucket.cacheReadTokens || 0)
262
+ hour.cacheWriteTokens += Number(bucket.cacheWriteTokens || 0)
263
+ hour.totalTokens += Number(bucket.totalTokens || 0)
264
+ hour.costUsd += Number(bucket.costUsd || 0)
265
+ hour.pricedRequests += Number(bucket.pricedRequests || 0)
266
+ hour.unpricedRequests += Number(bucket.unpricedRequests || 0)
267
+ hour.ttftMsSum += Number(bucket.ttftMsSum || 0)
268
+ hour.latencyMsSum += Number(bucket.latencyMsSum || 0)
269
+ hour.firstAt = hour.firstAt ? Math.min(hour.firstAt, bucket.firstAt || at) : (bucket.firstAt || at)
270
+ hour.lastAt = Math.max(hour.lastAt, at)
271
+ if (bucket.model) hour.model = bucket.model
272
+ if (bucket.accountId) hour.accountId = bucket.accountId
273
+ const model = bucket.model || 'unknown'
274
+ if (!inMemoryStats.byHourModel[key]) inMemoryStats.byHourModel[key] = {}
275
+ inMemoryStats.byHourModel[key][model] = Number(inMemoryStats.byHourModel[key][model] || 0) + requests
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Record one completion event.
281
+ * @param {object} entry
228
282
  */
229
283
  export function recordUsage({
230
284
  model = 'unknown',
231
285
  accountId = 'default',
286
+ sessionId,
287
+ purpose,
232
288
  promptTokens = 0,
233
289
  completionTokens = 0,
234
290
  reasoningTokens = 0,
291
+ cacheReadTokens = 0,
292
+ cacheWriteTokens = 0,
235
293
  totalTokens = 0,
294
+ costUsd = 0,
295
+ priced = false,
296
+ ttftMs = 0,
297
+ latencyMs = 0,
236
298
  isError = false,
237
299
  is429 = false,
238
- statsPath
300
+ statsPath,
239
301
  } = {}) {
240
- if (statsPath) {
241
- switchStatsStorage(statsPath)
242
- }
243
- const now = new Date()
244
- const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
245
- const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
246
-
302
+ if (statsPath) switchStatsStorage(statsPath)
303
+ const now = Date.now()
304
+ const dayKey = new Date(now).toISOString().slice(0, 10)
305
+ const monthKey = dayKey.slice(0, 7)
247
306
  const safePrompt = Math.max(0, Number(promptTokens) || 0)
248
307
  const safeComp = Math.max(0, Number(completionTokens) || 0)
249
- const safeReasoning = Math.max(0, Number(reasoningTokens) || 0)
250
- const safeTotal = Math.max(safePrompt + safeComp, Number(totalTokens) || 0)
251
-
252
- // Totals
253
- inMemoryStats.totals.requests += 1
254
- if (isError) {
255
- inMemoryStats.totals.failed += 1
256
- } else {
257
- inMemoryStats.totals.successful += 1
258
- }
259
- if (is429) {
260
- inMemoryStats.totals.rateLimited429 += 1
261
- }
262
- inMemoryStats.totals.promptTokens += safePrompt
263
- inMemoryStats.totals.completionTokens += safeComp
264
- inMemoryStats.totals.reasoningTokens = (inMemoryStats.totals.reasoningTokens || 0) + safeReasoning
265
- inMemoryStats.totals.totalTokens += safeTotal
266
-
267
- // Helper to increment bucket
268
- function incrementBucket(container, key) {
269
- if (!container[key]) {
270
- container[key] = {
271
- requests: 0,
272
- promptTokens: 0,
273
- completionTokens: 0,
274
- reasoningTokens: 0,
275
- totalTokens: 0
276
- }
277
- }
278
- container[key].requests += 1
279
- container[key].promptTokens += safePrompt
280
- container[key].completionTokens += safeComp
281
- container[key].reasoningTokens = (container[key].reasoningTokens || 0) + safeReasoning
282
- container[key].totalTokens += safeTotal
308
+ const entry = {
309
+ now,
310
+ model,
311
+ accountId,
312
+ purpose: purpose || '',
313
+ promptTokens: safePrompt,
314
+ completionTokens: safeComp,
315
+ reasoningTokens: Math.max(0, Number(reasoningTokens) || 0),
316
+ cacheReadTokens: Math.max(0, Number(cacheReadTokens) || 0),
317
+ cacheWriteTokens: Math.max(0, Number(cacheWriteTokens) || 0),
318
+ totalTokens: Math.max(safePrompt + safeComp, Number(totalTokens) || 0),
319
+ costUsd: Math.max(0, Number(costUsd) || 0),
320
+ priced: Boolean(priced),
321
+ ttftMs: Math.max(0, Number(ttftMs) || 0),
322
+ latencyMs: Math.max(0, Number(latencyMs) || 0),
323
+ isError: Boolean(isError),
324
+ is429: Boolean(is429),
283
325
  }
284
326
 
285
- incrementBucket(inMemoryStats.byDay, dayKey)
286
- incrementBucket(inMemoryStats.byMonth, monthKey)
287
- incrementBucket(inMemoryStats.byModel, model)
288
- incrementBucket(inMemoryStats.byAccount, accountId)
289
-
327
+ addToBucket(inMemoryStats.totals, entry)
328
+ incrementBucket(inMemoryStats.byDay, dayKey, entry)
329
+ incrementBucket(inMemoryStats.byMonth, monthKey, entry)
330
+ incrementBucket(inMemoryStats.byModel, model, entry)
331
+ incrementBucket(inMemoryStats.byAccount, accountId, entry)
332
+ const localNow = new Date(now)
333
+ const hourKey = `${localDateKey(localNow)}T${String(localNow.getHours()).padStart(2, '0')}`
334
+ incrementBucket(inMemoryStats.byHour, hourKey, entry)
335
+ if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
336
+ inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
337
+ pruneHours(now)
338
+ if (sessionId) {
339
+ const key = String(sessionId)
340
+ incrementBucket(inMemoryStats.bySession, key, entry)
341
+ const bucket = inMemoryStats.bySession[key]
342
+ if (bucket) bucket.last = { ...entry, sessionId: key }
343
+ pruneSessions(now)
344
+ }
290
345
  queueSaveStats(statsPath)
291
346
  }
292
347
 
293
- /**
294
- * Retrieve current statistics summary.
295
- *
296
- * @returns {Object}
297
- */
298
348
  export function getStatsSummary() {
299
349
  return JSON.parse(JSON.stringify(inMemoryStats))
300
350
  }
301
351
 
302
352
  /**
303
- * Reset statistics to initial state and persist.
304
- *
305
- * @param {string} [customPath]
353
+ * Return the ClineBot-only aggregate for one DSH session.
354
+ * Returns null when the session has no recorded ClineBot traffic.
355
+ * @param {string} sessionId
306
356
  */
357
+ export function getSessionStats(sessionId) {
358
+ const key = String(sessionId || '').trim()
359
+ if (!key) return null
360
+ const bucket = inMemoryStats.bySession?.[key]
361
+ return bucket ? JSON.parse(JSON.stringify(bucket)) : null
362
+ }
363
+
307
364
  export function resetStats(customPath) {
308
365
  inMemoryStats = createEmptyStats()
309
366
  saveStatsSync(customPath)
310
367
  return inMemoryStats
311
368
  }
312
369
 
370
+ /**
371
+ * Hourly request heatmap for the last `days` local days.
372
+ * @param {number} [days]
373
+ * @param {number} [to]
374
+ */
375
+ export function getHeatmap(days = 7, to = Date.now(), model = '') {
376
+ const count = Math.max(1, Math.min(95, Number(days) || 7))
377
+ const end = new Date(Number.isFinite(Number(to)) ? Number(to) : Date.now())
378
+ const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - (count - 1))
379
+ const startKey = localDateKey(start)
380
+ const endKey = localDateKey(end)
381
+ const grid = Array.from({ length: 7 }, () => Array(24).fill(0))
382
+ const hours = Array(24).fill(0)
383
+ let total = 0
384
+
385
+ for (const [key, bucket] of Object.entries(inMemoryStats.byHour || {})) {
386
+ const datePart = key.slice(0, 10)
387
+ if (datePart < startKey || datePart > endKey) continue
388
+ const hour = Number(key.slice(11, 13))
389
+ if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue
390
+ const date = new Date(`${datePart}T00:00:00`)
391
+ if (Number.isNaN(date.getTime())) continue
392
+ const weekday = (date.getDay() + 6) % 7
393
+ const requests = model
394
+ ? Number(inMemoryStats.byHourModel?.[key]?.[model] || 0)
395
+ : Number(bucket.requests || 0)
396
+ if (requests <= 0) continue
397
+ grid[weekday][hour] += requests
398
+ hours[hour] += requests
399
+ total += requests
400
+ }
401
+
402
+ const modelTotals = new Map()
403
+ for (const [key, perModel] of Object.entries(inMemoryStats.byHourModel || {})) {
404
+ const datePart = key.slice(0, 10)
405
+ if (datePart < startKey || datePart > endKey) continue
406
+ for (const [id, value] of Object.entries(perModel || {})) {
407
+ if (!id) continue
408
+ modelTotals.set(id, (modelTotals.get(id) || 0) + Number(value || 0))
409
+ }
410
+ }
411
+ const models = [...modelTotals.entries()]
412
+ .filter(([, value]) => value > 0)
413
+ .map(([id, value]) => ({ model: id, total: value }))
414
+ .sort((a, b) => b.total - a.total)
415
+
416
+ return { total, grid, hours, models, days: count }
417
+ }
313
418
 
314
419
  /**
315
- * Export usage analytics as a formatted CSV string.
316
- *
317
- * @returns {string} CSV format
420
+ * Aggregate analytics for a time range.
421
+ * @param {{ from?: number|string, to?: number|string, groupBy?: string, limit?: number, model?: string, account?: string, heatmapDays?: number|string, heatmapModel?: string }} [query]
422
+ */
423
+ export function getAnalytics(query = {}) {
424
+ const now = Date.now()
425
+ const from = Number.isFinite(Number(query.from)) ? Number(query.from) : 0
426
+ const to = Number.isFinite(Number(query.to)) ? Number(query.to) : now
427
+ const groupBy = ['session', 'day', 'model', 'account'].includes(query.groupBy) ? query.groupBy : 'model'
428
+ const limit = Math.max(1, Math.min(500, Number(query.limit) || 50))
429
+ const source = groupBy === 'session'
430
+ ? inMemoryStats.bySession
431
+ : groupBy === 'day'
432
+ ? inMemoryStats.byDay
433
+ : groupBy === 'account'
434
+ ? inMemoryStats.byAccount
435
+ : inMemoryStats.byModel
436
+
437
+ const groups = []
438
+ for (const [key, bucket] of Object.entries(source || {})) {
439
+ const at = groupBy === 'day' ? Date.parse(`${key}T00:00:00Z`) : Number(bucket.lastAt || 0)
440
+ if (Number.isFinite(at) && (at < from || at > to)) continue
441
+ if (query.model && bucket.model && bucket.model !== query.model && key !== query.model) continue
442
+ if (query.account && bucket.accountId && bucket.accountId !== query.account && key !== query.account) continue
443
+ const requests = Number(bucket.requests || 0)
444
+ const successful = Number(bucket.successful || 0)
445
+ groups.push({
446
+ key,
447
+ label: key,
448
+ requests,
449
+ successful,
450
+ failed: Number(bucket.failed || 0),
451
+ rateLimited429: Number(bucket.rateLimited429 || 0),
452
+ promptTokens: Number(bucket.promptTokens || 0),
453
+ completionTokens: Number(bucket.completionTokens || 0),
454
+ reasoningTokens: Number(bucket.reasoningTokens || 0),
455
+ cacheReadTokens: Number(bucket.cacheReadTokens || 0),
456
+ cacheWriteTokens: Number(bucket.cacheWriteTokens || 0),
457
+ totalTokens: Number(bucket.totalTokens || 0),
458
+ costUsd: Number(bucket.costUsd || 0),
459
+ pricedRequests: Number(bucket.pricedRequests || 0),
460
+ unpricedRequests: Number(bucket.unpricedRequests || 0),
461
+ successRate: requests > 0 ? (successful / requests) * 100 : 0,
462
+ avgTtftMs: successful > 0 ? Number(bucket.ttftMsSum || 0) / successful : 0,
463
+ avgLatencyMs: requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0,
464
+ ttftMsSum: Number(bucket.ttftMsSum || 0),
465
+ latencyMsSum: Number(bucket.latencyMsSum || 0),
466
+ model: bucket.model || '',
467
+ accountId: bucket.accountId || '',
468
+ lastAt: Number(bucket.lastAt || 0),
469
+ })
470
+ }
471
+ groups.sort((a, b) => groupBy === 'session'
472
+ ? b.lastAt - a.lastAt
473
+ : (b.totalTokens - a.totalTokens) || (b.requests - a.requests))
474
+
475
+ const totals = emptyBucket()
476
+ for (const group of groups) {
477
+ totals.requests += group.requests
478
+ totals.successful += group.successful
479
+ totals.failed += group.failed
480
+ totals.rateLimited429 += group.rateLimited429
481
+ totals.promptTokens += group.promptTokens
482
+ totals.completionTokens += group.completionTokens
483
+ totals.reasoningTokens += group.reasoningTokens
484
+ totals.cacheReadTokens += group.cacheReadTokens
485
+ totals.cacheWriteTokens += group.cacheWriteTokens
486
+ totals.totalTokens += group.totalTokens
487
+ totals.costUsd += group.costUsd
488
+ totals.pricedRequests += group.pricedRequests
489
+ totals.unpricedRequests += group.unpricedRequests
490
+ totals.ttftMsSum += group.ttftMsSum
491
+ totals.latencyMsSum += group.latencyMsSum
492
+ if (group.lastAt) {
493
+ totals.firstAt = totals.firstAt ? Math.min(totals.firstAt, group.lastAt) : group.lastAt
494
+ totals.lastAt = Math.max(totals.lastAt, group.lastAt)
495
+ }
496
+ if (group.model) totals.model = group.model
497
+ if (group.accountId) totals.accountId = group.accountId
498
+ }
499
+ totals.successRate = totals.requests > 0 ? (totals.successful / totals.requests) * 100 : 0
500
+ totals.avgTtftMs = totals.successful > 0 ? totals.ttftMsSum / totals.successful : 0
501
+ totals.avgLatencyMs = totals.requests > 0 ? totals.latencyMsSum / totals.requests : 0
502
+
503
+ return {
504
+ range: { from, to },
505
+ totals,
506
+ groups: groups.slice(0, limit),
507
+ unpriced: Number(totals.unpricedRequests || 0),
508
+ heatmap: getHeatmap(query.heatmapDays, to, query.heatmapModel),
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Export CSV. The compact form keeps the historical schema; `detailed`
514
+ * appends cache, cost, TTFT and latency columns.
515
+ * @param {boolean} [detailed]
318
516
  */
319
- export function exportStatsAsCsv() {
320
- const lines = ['Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens']
517
+ export function exportStatsAsCsv(detailed = false) {
518
+ const header = detailed
519
+ ? 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,CacheReadTokens,CacheWriteTokens,TotalTokens,CostUsd,TtftMs,LatencyMs'
520
+ : 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,TotalTokens'
521
+ const lines = [header]
321
522
  const addRows = (dimension, obj) => {
322
523
  for (const [key, val] of Object.entries(obj || {})) {
323
- lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
524
+ if (detailed) {
525
+ lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
526
+ } else {
527
+ lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
528
+ }
324
529
  }
325
530
  }
326
531
  const totals = inMemoryStats.totals || {}
327
- lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
532
+ if (detailed) {
533
+ lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.cacheReadTokens || 0},${totals.cacheWriteTokens || 0},${totals.totalTokens || 0},${Number(totals.costUsd || 0).toFixed(6)},${Math.round(totals.ttftMsSum || 0)},${Math.round(totals.latencyMsSum || 0)}`)
534
+ } else {
535
+ lines.push(`Total,"all",${totals.requests || 0},${totals.promptTokens || 0},${totals.completionTokens || 0},${totals.reasoningTokens || 0},${totals.totalTokens || 0}`)
536
+ }
328
537
  addRows('Day', inMemoryStats.byDay)
329
538
  addRows('Month', inMemoryStats.byMonth)
330
539
  addRows('Model', inMemoryStats.byModel)
331
540
  addRows('Account', inMemoryStats.byAccount)
541
+ if (detailed) {
542
+ addRows('Session', inMemoryStats.bySession)
543
+ addRows('Hour', inMemoryStats.byHour)
544
+ }
332
545
  return lines.join('\n')
333
546
  }