tokenrace 0.1.0
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.
- package/README.md +2 -0
- package/bin/cli.js +34 -0
- package/dist/assets/index-BincV2cE.css +1 -0
- package/dist/assets/index-swI7z6mC.js +187 -0
- package/dist/index.html +13 -0
- package/package.json +26 -0
- package/src/api-routes.js +180 -0
- package/src/otlp-parser.js +193 -0
- package/src/server.js +118 -0
- package/src/store.js +763 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js
|
|
3
|
+
*
|
|
4
|
+
* Estado en memoria del servidor, getters para la API REST,
|
|
5
|
+
* y persistencia en ~/.tokenrace/data.json.
|
|
6
|
+
*
|
|
7
|
+
* Reglas:
|
|
8
|
+
* - Nunca lanzar excepción — si falla escribir a disco, loguearlo y continuar.
|
|
9
|
+
* - Sin estado global mutable excepto el objeto `state` local.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
// ─── Ruta de persistencia ───────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const HOME_DATA_DIR = path.join(os.homedir(), '.tokenrace')
|
|
19
|
+
let dataDir = HOME_DATA_DIR
|
|
20
|
+
let dataFile = path.join(HOME_DATA_DIR, 'data.json')
|
|
21
|
+
|
|
22
|
+
// Solo para tests — redirige la ruta de datos a un directorio temporal
|
|
23
|
+
export function setDataPathForTesting(newPath) {
|
|
24
|
+
dataFile = newPath
|
|
25
|
+
dataDir = path.dirname(newPath)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Estado en memoria ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const state = {
|
|
31
|
+
timeseries: new Map(), // metricName → [{ ts, value, labels }]
|
|
32
|
+
sessions: new Map(), // sessionId → SessionData
|
|
33
|
+
sessionMappings: new Map(), // sessionId → projectName
|
|
34
|
+
ignoredSessions: new Set(), // sessionIds ignorados
|
|
35
|
+
events: [], // buffer circular, max 1000
|
|
36
|
+
eventIndex: 0,
|
|
37
|
+
projects: new Map(), // projectName → ProjectAggregates
|
|
38
|
+
tools: new Map(), // toolName → ToolStats
|
|
39
|
+
agents: new Map(), // agentId → AgentData
|
|
40
|
+
models: new Map(), // modelName → ModelStats
|
|
41
|
+
lastSeen: null,
|
|
42
|
+
startTime: Date.now(),
|
|
43
|
+
totalEvents: 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Helpers internos ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resuelve el proyecto de una sesión.
|
|
50
|
+
* Prioridad: mapping manual > resource project > null
|
|
51
|
+
*/
|
|
52
|
+
function resolveProject(sessionId, resourceProject) {
|
|
53
|
+
return state.sessionMappings.get(sessionId) ?? resourceProject ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TIME_MULTIPLIERS = { d: 86_400_000, h: 3_600_000, m: 60_000 }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parsea un rango temporal "now-Xd", "now-Xh", "now-Xm", "all" o falsy.
|
|
60
|
+
* Devuelve el timestamp mínimo (ms) a incluir.
|
|
61
|
+
*/
|
|
62
|
+
function parseTimeRange(from) {
|
|
63
|
+
if (!from || from === 'all') return 0
|
|
64
|
+
|
|
65
|
+
const match = /^now-(\d+)([dhm])$/.exec(from)
|
|
66
|
+
if (!match) return 0
|
|
67
|
+
|
|
68
|
+
return Date.now() - Number(match[1]) * TIME_MULTIPLIERS[match[2]]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parsea un tamaño de bucket ("1h", "1d", "5m") a ms.
|
|
73
|
+
* Default: 1 hora.
|
|
74
|
+
*/
|
|
75
|
+
function parseBucket(bucket) {
|
|
76
|
+
if (!bucket) return 3_600_000
|
|
77
|
+
const match = /^(\d+)([dhm])$/.exec(bucket)
|
|
78
|
+
if (!match) return 3_600_000
|
|
79
|
+
return Number(match[1]) * TIME_MULTIPLIERS[match[2]]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reconstruye state.projects desde cero usando state.sessions y state.timeseries.
|
|
84
|
+
*/
|
|
85
|
+
function rebuildProjectAggregates() {
|
|
86
|
+
state.projects.clear()
|
|
87
|
+
|
|
88
|
+
// Reconstruir desde sesiones
|
|
89
|
+
for (const session of state.sessions.values()) {
|
|
90
|
+
const project = resolveProject(session.sessionId, session.project)
|
|
91
|
+
if (!project) continue
|
|
92
|
+
|
|
93
|
+
if (!state.projects.has(project)) {
|
|
94
|
+
state.projects.set(project, {
|
|
95
|
+
sessions: new Set(),
|
|
96
|
+
commits: 0, linesAdded: 0, linesRemoved: 0
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
const proj = state.projects.get(project)
|
|
100
|
+
proj.sessions.add(session.sessionId)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Reconstruir commits y líneas desde timeseries
|
|
104
|
+
const commitMetrics = ['claude_code.commits']
|
|
105
|
+
const linesAddedM = ['claude_code.lines_added']
|
|
106
|
+
const linesRemovedM = ['claude_code.lines_removed']
|
|
107
|
+
|
|
108
|
+
for (const [metricName, points] of state.timeseries.entries()) {
|
|
109
|
+
let field = null
|
|
110
|
+
if (commitMetrics.includes(metricName)) field = 'commits'
|
|
111
|
+
if (linesAddedM.includes(metricName)) field = 'linesAdded'
|
|
112
|
+
if (linesRemovedM.includes(metricName)) field = 'linesRemoved'
|
|
113
|
+
if (!field) continue
|
|
114
|
+
|
|
115
|
+
for (const point of points) {
|
|
116
|
+
const project = resolveProject(point.labels['session.id'], point.labels.project)
|
|
117
|
+
if (!project) continue
|
|
118
|
+
if (!state.projects.has(project)) {
|
|
119
|
+
state.projects.set(project, {
|
|
120
|
+
sessions: new Set(),
|
|
121
|
+
commits: 0, linesAdded: 0, linesRemoved: 0
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
state.projects.get(project)[field] += point.value
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Debounce: guarda a disco en el siguiente tick */
|
|
130
|
+
function scheduleSave() {
|
|
131
|
+
setTimeout(() => saveSync(), 0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Mutaciones ──────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Procesa un punto de métrica normalizado.
|
|
138
|
+
* @param {{ name, value, timestamp, labels }} metric
|
|
139
|
+
*/
|
|
140
|
+
export function processMetric({ name, value, timestamp, labels }) {
|
|
141
|
+
const sessionId = labels['session.id']
|
|
142
|
+
|
|
143
|
+
// Saltar sesiones ignoradas
|
|
144
|
+
if (sessionId && state.ignoredSessions.has(sessionId)) return
|
|
145
|
+
|
|
146
|
+
const project = resolveProject(sessionId, labels.project)
|
|
147
|
+
const model = labels.model ?? null
|
|
148
|
+
|
|
149
|
+
// ── Timeseries ──
|
|
150
|
+
if (!state.timeseries.has(name)) state.timeseries.set(name, [])
|
|
151
|
+
state.timeseries.get(name).push({ ts: timestamp, value, labels })
|
|
152
|
+
|
|
153
|
+
// ── Sesiones ──
|
|
154
|
+
if (sessionId) {
|
|
155
|
+
if (!state.sessions.has(sessionId)) {
|
|
156
|
+
state.sessions.set(sessionId, {
|
|
157
|
+
sessionId,
|
|
158
|
+
project,
|
|
159
|
+
feature: labels.feature ?? null,
|
|
160
|
+
model,
|
|
161
|
+
startTime: timestamp,
|
|
162
|
+
lastSeen: timestamp,
|
|
163
|
+
durationActiveMs: 0,
|
|
164
|
+
tokensInput: 0,
|
|
165
|
+
tokensOutput: 0,
|
|
166
|
+
tokensCache: 0,
|
|
167
|
+
cost: 0,
|
|
168
|
+
apiRequests: 0,
|
|
169
|
+
toolCalls: 0
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const session = state.sessions.get(sessionId)
|
|
174
|
+
session.lastSeen = Math.max(session.lastSeen, timestamp)
|
|
175
|
+
if (model && !session.model) session.model = model
|
|
176
|
+
|
|
177
|
+
switch (name) {
|
|
178
|
+
case 'claude_code.tokens.input':
|
|
179
|
+
session.tokensInput += value; break
|
|
180
|
+
case 'claude_code.tokens.output':
|
|
181
|
+
session.tokensOutput += value; break
|
|
182
|
+
case 'claude_code.tokens.cache.read':
|
|
183
|
+
case 'claude_code.tokens.cache.creation':
|
|
184
|
+
session.tokensCache += value; break
|
|
185
|
+
case 'claude_code.cost':
|
|
186
|
+
session.cost += value; break
|
|
187
|
+
case 'claude_code.active_time':
|
|
188
|
+
session.durationActiveMs += value; break
|
|
189
|
+
case 'claude_code.api_requests':
|
|
190
|
+
session.apiRequests += value; break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Proyectos ──
|
|
195
|
+
if (project) {
|
|
196
|
+
if (!state.projects.has(project)) {
|
|
197
|
+
state.projects.set(project, {
|
|
198
|
+
sessions: new Set(),
|
|
199
|
+
commits: 0, linesAdded: 0, linesRemoved: 0
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const proj = state.projects.get(project)
|
|
203
|
+
if (sessionId) proj.sessions.add(sessionId)
|
|
204
|
+
|
|
205
|
+
switch (name) {
|
|
206
|
+
case 'claude_code.commits':
|
|
207
|
+
proj.commits += value; break
|
|
208
|
+
case 'claude_code.lines_added':
|
|
209
|
+
proj.linesAdded += value; break
|
|
210
|
+
case 'claude_code.lines_removed':
|
|
211
|
+
proj.linesRemoved += value; break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Modelos ──
|
|
216
|
+
if (model) {
|
|
217
|
+
if (!state.models.has(model)) {
|
|
218
|
+
state.models.set(model, {
|
|
219
|
+
tokensInput: 0, tokensOutput: 0, cost: 0, requests: 0
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
const modelStats = state.models.get(model)
|
|
223
|
+
switch (name) {
|
|
224
|
+
case 'claude_code.tokens.input':
|
|
225
|
+
modelStats.tokensInput += value; break
|
|
226
|
+
case 'claude_code.tokens.output':
|
|
227
|
+
modelStats.tokensOutput += value; break
|
|
228
|
+
case 'claude_code.cost':
|
|
229
|
+
modelStats.cost += value; break
|
|
230
|
+
case 'claude_code.api_requests':
|
|
231
|
+
modelStats.requests += value; break
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
state.lastSeen = timestamp
|
|
236
|
+
state.totalEvents++
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Procesa un evento normalizado.
|
|
241
|
+
* @param {{ eventName, timestamp, severity, attributes }} event
|
|
242
|
+
* @returns {Object} El evento procesado (para broadcast SSE)
|
|
243
|
+
*/
|
|
244
|
+
export function processEvent({ eventName, timestamp, severity, attributes }) {
|
|
245
|
+
const sessionId = attributes['session.id']
|
|
246
|
+
const project = resolveProject(sessionId, attributes.project)
|
|
247
|
+
const model = attributes.model ?? null
|
|
248
|
+
|
|
249
|
+
const eventObj = {
|
|
250
|
+
timestamp,
|
|
251
|
+
eventName,
|
|
252
|
+
sessionId,
|
|
253
|
+
project,
|
|
254
|
+
model,
|
|
255
|
+
attributes
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Buffer circular — sobreescribir cuando alcanza 1000
|
|
259
|
+
if (state.events.length < 1000) {
|
|
260
|
+
state.events.push(eventObj)
|
|
261
|
+
} else {
|
|
262
|
+
state.events[state.eventIndex % 1000] = eventObj
|
|
263
|
+
}
|
|
264
|
+
state.eventIndex++
|
|
265
|
+
|
|
266
|
+
// ── Tool stats ──
|
|
267
|
+
if (eventName === 'tool_use') {
|
|
268
|
+
const toolName = attributes['tool.name'] ?? attributes.tool ?? 'unknown'
|
|
269
|
+
|
|
270
|
+
if (!state.tools.has(toolName)) {
|
|
271
|
+
state.tools.set(toolName, { count: 0, successes: 0, totalDurationMs: 0 })
|
|
272
|
+
}
|
|
273
|
+
const tool = state.tools.get(toolName)
|
|
274
|
+
tool.count++
|
|
275
|
+
|
|
276
|
+
if (attributes.success === true || attributes['tool.success'] === true) {
|
|
277
|
+
tool.successes++
|
|
278
|
+
}
|
|
279
|
+
if (attributes['tool.duration_ms'] !== undefined) {
|
|
280
|
+
tool.totalDurationMs += Number(attributes['tool.duration_ms'])
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Incrementar toolCalls en la sesión
|
|
284
|
+
if (sessionId && state.sessions.has(sessionId)) {
|
|
285
|
+
state.sessions.get(sessionId).toolCalls++
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
state.lastSeen = timestamp
|
|
290
|
+
state.totalEvents++
|
|
291
|
+
|
|
292
|
+
return eventObj
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Procesa un span normalizado.
|
|
297
|
+
* Solo actúa si el span tiene agentId.
|
|
298
|
+
* @param {Object} span
|
|
299
|
+
*/
|
|
300
|
+
export function processTrace(span) {
|
|
301
|
+
const attrs = span.attributes
|
|
302
|
+
const agentId = attrs['agent.id'] ?? attrs['gen_ai.agent.id'] ?? null
|
|
303
|
+
if (!agentId) return
|
|
304
|
+
|
|
305
|
+
if (!state.agents.has(agentId)) {
|
|
306
|
+
state.agents.set(agentId, {
|
|
307
|
+
agentId,
|
|
308
|
+
parentAgentId: attrs['agent.parent_id'] ?? null,
|
|
309
|
+
tokensInput: 0,
|
|
310
|
+
tokensOutput: 0,
|
|
311
|
+
cost: 0,
|
|
312
|
+
toolCalls: 0,
|
|
313
|
+
durationMs: 0
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const agent = state.agents.get(agentId)
|
|
318
|
+
const duration = span.endTime - span.startTime
|
|
319
|
+
if (duration > 0) agent.durationMs += duration
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Asigna un proyecto a una sesión, con aplicación retroactiva.
|
|
324
|
+
* @param {string} sessionId
|
|
325
|
+
* @param {string} project
|
|
326
|
+
*/
|
|
327
|
+
export function labelSession(sessionId, project) {
|
|
328
|
+
state.sessionMappings.set(sessionId, project)
|
|
329
|
+
|
|
330
|
+
// Aplicar en sesión existente
|
|
331
|
+
if (state.sessions.has(sessionId)) {
|
|
332
|
+
state.sessions.get(sessionId).project = project
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Aplicar retroactivamente en timeseries
|
|
336
|
+
for (const points of state.timeseries.values()) {
|
|
337
|
+
for (const point of points) {
|
|
338
|
+
if (point.labels['session.id'] === sessionId) {
|
|
339
|
+
point.labels.project = project
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
rebuildProjectAggregates()
|
|
345
|
+
scheduleSave()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Marca una sesión como ignorada.
|
|
350
|
+
* @param {string} sessionId
|
|
351
|
+
*/
|
|
352
|
+
export function ignoreSession(sessionId) {
|
|
353
|
+
state.ignoredSessions.add(sessionId)
|
|
354
|
+
scheduleSave()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Resetea todo el estado en memoria y guarda el estado vacío a disco.
|
|
359
|
+
*/
|
|
360
|
+
export function reset() {
|
|
361
|
+
state.timeseries.clear()
|
|
362
|
+
state.sessions.clear()
|
|
363
|
+
state.sessionMappings.clear()
|
|
364
|
+
state.ignoredSessions.clear()
|
|
365
|
+
state.events.length = 0
|
|
366
|
+
state.eventIndex = 0
|
|
367
|
+
state.projects.clear()
|
|
368
|
+
state.tools.clear()
|
|
369
|
+
state.agents.clear()
|
|
370
|
+
state.models.clear()
|
|
371
|
+
state.lastSeen = null
|
|
372
|
+
state.startTime = Date.now()
|
|
373
|
+
state.totalEvents = 0
|
|
374
|
+
|
|
375
|
+
saveSync()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── Getters ─────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Estado de conexión del servidor.
|
|
382
|
+
*/
|
|
383
|
+
export function getStatus() {
|
|
384
|
+
return {
|
|
385
|
+
connected: state.lastSeen !== null,
|
|
386
|
+
lastSeen: state.lastSeen,
|
|
387
|
+
sessionCount: Array.from(state.sessions.keys())
|
|
388
|
+
.filter(id => !state.ignoredSessions.has(id)).length,
|
|
389
|
+
totalEvents: state.totalEvents,
|
|
390
|
+
uptime: Date.now() - state.startTime
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Resumen agregado filtrado por rango temporal.
|
|
396
|
+
* @param {string} from - Rango temporal ("now-7d", "now-24h", "all")
|
|
397
|
+
*/
|
|
398
|
+
export function getSummary(from) {
|
|
399
|
+
const minTs = parseTimeRange(from)
|
|
400
|
+
|
|
401
|
+
let tokensInput = 0
|
|
402
|
+
let tokensOutput = 0
|
|
403
|
+
let tokensCache = 0
|
|
404
|
+
let cost = 0
|
|
405
|
+
let activeTimeMs = 0
|
|
406
|
+
const sessionSet = new Set()
|
|
407
|
+
|
|
408
|
+
for (const session of state.sessions.values()) {
|
|
409
|
+
if (state.ignoredSessions.has(session.sessionId)) continue
|
|
410
|
+
if (session.lastSeen < minTs) continue
|
|
411
|
+
tokensInput += session.tokensInput
|
|
412
|
+
tokensOutput += session.tokensOutput
|
|
413
|
+
tokensCache += session.tokensCache
|
|
414
|
+
cost += session.cost
|
|
415
|
+
activeTimeMs += session.durationActiveMs
|
|
416
|
+
sessionSet.add(session.sessionId)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Commits, PRs y líneas desde timeseries
|
|
420
|
+
let commits = 0
|
|
421
|
+
let pullRequests = 0
|
|
422
|
+
let linesAdded = 0
|
|
423
|
+
let linesRemoved = 0
|
|
424
|
+
|
|
425
|
+
const tsMap = {
|
|
426
|
+
'claude_code.commits': (v) => { commits += v },
|
|
427
|
+
'claude_code.pull_requests': (v) => { pullRequests += v },
|
|
428
|
+
'claude_code.lines_added': (v) => { linesAdded += v },
|
|
429
|
+
'claude_code.lines_removed': (v) => { linesRemoved += v }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const [metric, accum] of Object.entries(tsMap)) {
|
|
433
|
+
for (const point of state.timeseries.get(metric) ?? []) {
|
|
434
|
+
if (point.ts >= minTs) accum(point.value)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const efficiency = tokensInput > 0 ? tokensOutput / tokensInput : 0
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
tokens: { input: tokensInput, output: tokensOutput, cache: tokensCache },
|
|
442
|
+
cost,
|
|
443
|
+
activeTimeMs,
|
|
444
|
+
sessions: sessionSet.size,
|
|
445
|
+
commits,
|
|
446
|
+
pullRequests,
|
|
447
|
+
linesAdded,
|
|
448
|
+
linesRemoved,
|
|
449
|
+
efficiency
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Serie temporal de una métrica, agrupada en buckets.
|
|
455
|
+
* @param {string} metric
|
|
456
|
+
* @param {string} from
|
|
457
|
+
* @param {string} bucket
|
|
458
|
+
* @returns {Array<{timestamp, value}>}
|
|
459
|
+
*/
|
|
460
|
+
export function getTimeseries(metric, from, bucket) {
|
|
461
|
+
const minTs = parseTimeRange(from)
|
|
462
|
+
const bucketMs = parseBucket(bucket)
|
|
463
|
+
const points = state.timeseries.get(metric) ?? []
|
|
464
|
+
|
|
465
|
+
const buckets = new Map()
|
|
466
|
+
for (const point of points) {
|
|
467
|
+
if (point.ts < minTs) continue
|
|
468
|
+
const key = Math.floor(point.ts / bucketMs) * bucketMs
|
|
469
|
+
buckets.set(key, (buckets.get(key) ?? 0) + point.value)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return Array.from(buckets.entries())
|
|
473
|
+
.map(([timestamp, value]) => ({ timestamp, value }))
|
|
474
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Proyectos con sus métricas agregadas filtradas por rango temporal.
|
|
479
|
+
* @param {string} from
|
|
480
|
+
*/
|
|
481
|
+
export function getProjects(from) {
|
|
482
|
+
const minTs = parseTimeRange(from)
|
|
483
|
+
const result = []
|
|
484
|
+
|
|
485
|
+
for (const [project, proj] of state.projects.entries()) {
|
|
486
|
+
// Filtrar sesiones activas en el rango
|
|
487
|
+
const activeSessions = new Set()
|
|
488
|
+
let cost = 0, tokensInput = 0, tokensOutput = 0, tokensCache = 0
|
|
489
|
+
|
|
490
|
+
for (const sessionId of proj.sessions) {
|
|
491
|
+
const session = state.sessions.get(sessionId)
|
|
492
|
+
if (!session || session.lastSeen < minTs) continue
|
|
493
|
+
activeSessions.add(sessionId)
|
|
494
|
+
cost += session.cost
|
|
495
|
+
tokensInput += session.tokensInput
|
|
496
|
+
tokensOutput += session.tokensOutput
|
|
497
|
+
tokensCache += session.tokensCache
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (activeSessions.size === 0 && minTs > 0) continue
|
|
501
|
+
|
|
502
|
+
// Commits y líneas filtrados por rango
|
|
503
|
+
let commits = 0, linesAdded = 0, linesRemoved = 0
|
|
504
|
+
const tsMap = {
|
|
505
|
+
'claude_code.commits': (v) => { commits += v },
|
|
506
|
+
'claude_code.lines_added': (v) => { linesAdded += v },
|
|
507
|
+
'claude_code.lines_removed': (v) => { linesRemoved += v }
|
|
508
|
+
}
|
|
509
|
+
for (const [metric, accum] of Object.entries(tsMap)) {
|
|
510
|
+
for (const point of state.timeseries.get(metric) ?? []) {
|
|
511
|
+
const pointProject = resolveProject(point.labels['session.id'], point.labels.project)
|
|
512
|
+
if (pointProject === project && point.ts >= minTs) accum(point.value)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const cacheHitRate = tokensInput > 0 ? tokensCache / tokensInput : 0
|
|
517
|
+
|
|
518
|
+
result.push({
|
|
519
|
+
project,
|
|
520
|
+
cost,
|
|
521
|
+
tokensInput,
|
|
522
|
+
tokensOutput,
|
|
523
|
+
cacheHitRate,
|
|
524
|
+
sessions: activeSessions.size,
|
|
525
|
+
commits,
|
|
526
|
+
linesAdded,
|
|
527
|
+
linesRemoved
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return result.sort((a, b) => b.cost - a.cost)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Lista de sesiones, opcionalmente filtradas por proyecto.
|
|
536
|
+
* @param {{ limit?, project? }} options
|
|
537
|
+
*/
|
|
538
|
+
export function getSessions({ limit = 50, project = null } = {}) {
|
|
539
|
+
let sessions = Array.from(state.sessions.values())
|
|
540
|
+
.filter(s => !state.ignoredSessions.has(s.sessionId))
|
|
541
|
+
|
|
542
|
+
if (project) {
|
|
543
|
+
sessions = sessions.filter(s => resolveProject(s.sessionId, s.project) === project)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
sessions.sort((a, b) => b.startTime - a.startTime)
|
|
547
|
+
|
|
548
|
+
return sessions.slice(0, limit).map(s => ({
|
|
549
|
+
...s,
|
|
550
|
+
project: resolveProject(s.sessionId, s.project)
|
|
551
|
+
}))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Sesiones sin proyecto asignado y que no están ignoradas.
|
|
556
|
+
*/
|
|
557
|
+
export function getUnlabeledSessions() {
|
|
558
|
+
const result = []
|
|
559
|
+
for (const session of state.sessions.values()) {
|
|
560
|
+
if (state.ignoredSessions.has(session.sessionId)) continue
|
|
561
|
+
if (resolveProject(session.sessionId, session.project) !== null) continue
|
|
562
|
+
result.push({
|
|
563
|
+
sessionId: session.sessionId,
|
|
564
|
+
model: session.model,
|
|
565
|
+
timestamp: session.startTime,
|
|
566
|
+
tokensInput: session.tokensInput
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
return result
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Últimos N eventos de una sesión.
|
|
574
|
+
* @param {string} sessionId
|
|
575
|
+
* @param {number} limit
|
|
576
|
+
*/
|
|
577
|
+
export function getSessionEvents(sessionId, limit = 100) {
|
|
578
|
+
return getEvents({ limit, sessionId })
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Lista de eventos filtrados y ordenados cronológicamente (más recientes primero).
|
|
583
|
+
* @param {{ limit?, type?, project?, sessionId? }} options
|
|
584
|
+
*/
|
|
585
|
+
export function getEvents({ limit = 200, type = null, project = null, sessionId = null } = {}) {
|
|
586
|
+
// Ordenar el buffer circular por timestamp (más reciente primero)
|
|
587
|
+
let events = state.events
|
|
588
|
+
.slice()
|
|
589
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
590
|
+
|
|
591
|
+
if (type) {
|
|
592
|
+
events = events.filter(e => e.eventName === type || e.eventName.includes(type))
|
|
593
|
+
}
|
|
594
|
+
if (project) {
|
|
595
|
+
events = events.filter(e => e.project === project)
|
|
596
|
+
}
|
|
597
|
+
if (sessionId) {
|
|
598
|
+
events = events.filter(e => e.sessionId === sessionId)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return events.slice(0, limit)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Estadísticas de uso de herramientas.
|
|
606
|
+
* @param {string} from
|
|
607
|
+
*/
|
|
608
|
+
export function getTools(from) {
|
|
609
|
+
const minTs = parseTimeRange(from)
|
|
610
|
+
|
|
611
|
+
const usage = Array.from(state.tools.entries()).map(([toolName, stats]) => ({
|
|
612
|
+
toolName,
|
|
613
|
+
count: stats.count,
|
|
614
|
+
successRate: stats.count > 0 ? stats.successes / stats.count : 0,
|
|
615
|
+
avgDurationMs: stats.count > 0 ? stats.totalDurationMs / stats.count : 0
|
|
616
|
+
})).sort((a, b) => b.count - a.count)
|
|
617
|
+
|
|
618
|
+
// Tasa de aprobación/rechazo desde eventos en el rango
|
|
619
|
+
let approved = 0
|
|
620
|
+
let rejected = 0
|
|
621
|
+
for (const event of state.events) {
|
|
622
|
+
if (event.timestamp < minTs) continue
|
|
623
|
+
if (event.eventName !== 'tool_use' && !event.eventName.includes('tool')) continue
|
|
624
|
+
if (event.attributes.approved === true) approved++
|
|
625
|
+
if (event.attributes.approved === false) rejected++
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return { usage, decisionRate: { approved, rejected } }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Lista de agentes registrados.
|
|
633
|
+
*/
|
|
634
|
+
export function getAgents() {
|
|
635
|
+
return Array.from(state.agents.values())
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Estadísticas por modelo ordenadas por coste descendente.
|
|
640
|
+
* @param {string} from
|
|
641
|
+
*/
|
|
642
|
+
export function getModels(from) {
|
|
643
|
+
return Array.from(state.models.entries())
|
|
644
|
+
.map(([model, stats]) => ({
|
|
645
|
+
model,
|
|
646
|
+
requests: stats.requests,
|
|
647
|
+
tokensInput: stats.tokensInput,
|
|
648
|
+
tokensOutput: stats.tokensOutput,
|
|
649
|
+
cost: stats.cost,
|
|
650
|
+
avgLatencyMs: 0,
|
|
651
|
+
avgTtftMs: 0
|
|
652
|
+
}))
|
|
653
|
+
.sort((a, b) => b.cost - a.cost)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── Persistencia ─────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Guarda el estado completo en disco de forma síncrona.
|
|
660
|
+
* Nunca lanza excepción.
|
|
661
|
+
*/
|
|
662
|
+
export function saveSync() {
|
|
663
|
+
try {
|
|
664
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
665
|
+
|
|
666
|
+
const data = {
|
|
667
|
+
timeseries: Array.from(state.timeseries.entries()),
|
|
668
|
+
sessions: Array.from(state.sessions.values()),
|
|
669
|
+
sessionMappings: Array.from(state.sessionMappings.entries()),
|
|
670
|
+
ignoredSessions: Array.from(state.ignoredSessions),
|
|
671
|
+
events: state.events,
|
|
672
|
+
eventIndex: state.eventIndex,
|
|
673
|
+
totalEvents: state.totalEvents,
|
|
674
|
+
startTime: state.startTime
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
fs.writeFileSync(dataFile, JSON.stringify(data))
|
|
678
|
+
} catch (err) {
|
|
679
|
+
console.error('[store] Error guardando datos en disco:', err.message)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Carga el estado desde disco al arrancar.
|
|
685
|
+
* Si el archivo no existe o está corrupto, continúa con estado vacío.
|
|
686
|
+
*/
|
|
687
|
+
export function loadFromDisk() {
|
|
688
|
+
try {
|
|
689
|
+
const raw = fs.readFileSync(dataFile, 'utf8')
|
|
690
|
+
const data = JSON.parse(raw)
|
|
691
|
+
|
|
692
|
+
// Restaurar timeseries (Map de arrays)
|
|
693
|
+
if (Array.isArray(data.timeseries)) {
|
|
694
|
+
for (const [key, points] of data.timeseries) {
|
|
695
|
+
state.timeseries.set(key, points)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Restaurar sesiones
|
|
700
|
+
if (Array.isArray(data.sessions)) {
|
|
701
|
+
for (const session of data.sessions) {
|
|
702
|
+
state.sessions.set(session.sessionId, session)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Restaurar session mappings
|
|
707
|
+
if (Array.isArray(data.sessionMappings)) {
|
|
708
|
+
for (const [k, v] of data.sessionMappings) {
|
|
709
|
+
state.sessionMappings.set(k, v)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Restaurar sesiones ignoradas
|
|
714
|
+
if (Array.isArray(data.ignoredSessions)) {
|
|
715
|
+
for (const id of data.ignoredSessions) {
|
|
716
|
+
state.ignoredSessions.add(id)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Restaurar buffer de eventos
|
|
721
|
+
if (Array.isArray(data.events)) {
|
|
722
|
+
state.events.push(...data.events)
|
|
723
|
+
}
|
|
724
|
+
if (typeof data.eventIndex === 'number') {
|
|
725
|
+
state.eventIndex = data.eventIndex
|
|
726
|
+
}
|
|
727
|
+
if (typeof data.totalEvents === 'number') {
|
|
728
|
+
state.totalEvents = data.totalEvents
|
|
729
|
+
}
|
|
730
|
+
if (typeof data.startTime === 'number') {
|
|
731
|
+
state.startTime = data.startTime
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Re-aplicar mappings a sesiones y timeseries
|
|
735
|
+
for (const [sessionId, project] of state.sessionMappings.entries()) {
|
|
736
|
+
if (state.sessions.has(sessionId)) {
|
|
737
|
+
state.sessions.get(sessionId).project = project
|
|
738
|
+
}
|
|
739
|
+
for (const points of state.timeseries.values()) {
|
|
740
|
+
for (const point of points) {
|
|
741
|
+
if (point.labels['session.id'] === sessionId) {
|
|
742
|
+
point.labels.project = project
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
rebuildProjectAggregates()
|
|
749
|
+
} catch (err) {
|
|
750
|
+
// Archivo no existe o corrupto — arrancar limpio
|
|
751
|
+
if (err.code !== 'ENOENT') {
|
|
752
|
+
console.error('[store] Error cargando datos desde disco:', err.message)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Inicia el guardado automático cada 60 segundos.
|
|
759
|
+
* @returns {NodeJS.Timeout} Interval ID para cancelar si es necesario
|
|
760
|
+
*/
|
|
761
|
+
export function startAutoSave() {
|
|
762
|
+
return setInterval(saveSync, 60_000)
|
|
763
|
+
}
|