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.
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>tokenrace</title>
7
+ <script type="module" crossorigin src="/assets/index-swI7z6mC.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BincV2cE.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "tokenrace",
3
+ "version": "0.1.0",
4
+ "description": "Monitor en tiempo real para Claude Code",
5
+ "bin": { "tokenrace": "./bin/cli.js" },
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node bin/cli.js",
9
+ "dev:server": "node bin/cli.js",
10
+ "dev:web": "cd web && npm run dev",
11
+ "build": "cd web && npm install && npm run build",
12
+ "test": "node --test test/*.test.js",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "dependencies": {
16
+ "express": "^4.18.0",
17
+ "open": "^10.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "supertest": "^7.0.0"
21
+ },
22
+ "files": ["bin/", "src/", "dist/"],
23
+ "engines": { "node": ">=18" },
24
+ "keywords": ["claude", "claude-code", "telemetry", "dashboard", "opentelemetry"],
25
+ "license": "MIT"
26
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * api-routes.js
3
+ *
4
+ * Router Express con todos los endpoints REST, SSE y la función broadcast()
5
+ * para emitir eventos a los clientes conectados.
6
+ *
7
+ * Exports:
8
+ * createRouter() → Express Router con todas las rutas /api/*
9
+ * broadcast(type, payload) → emite evento SSE a todos los clientes conectados
10
+ */
11
+
12
+ import { Router } from 'express'
13
+ import {
14
+ getStatus, getSummary, getTimeseries, getProjects,
15
+ getSessions, getUnlabeledSessions, getSessionEvents,
16
+ getEvents, getTools, getAgents, getModels,
17
+ labelSession, ignoreSession, reset
18
+ } from './store.js'
19
+
20
+ // ─── Clientes SSE activos ────────────────────────────────────────────────────
21
+ // Mapa de clientes SSE activos: clientId → res
22
+ const sseClients = new Map()
23
+ let _nextClientId = 0
24
+
25
+ // ─── Broadcast ───────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Emite un evento SSE a todos los clientes conectados.
29
+ * @param {string} type - Tipo de evento
30
+ * @param {*} payload - Datos a enviar
31
+ */
32
+ export function broadcast(type, payload) {
33
+ const message = `data: ${JSON.stringify({ type, payload })}\n\n`
34
+ for (const [, res] of sseClients) {
35
+ try { res.write(message) } catch { /* cliente desconectado */ }
36
+ }
37
+ }
38
+
39
+ // ─── Router ──────────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Crea y devuelve el router Express con todas las rutas /api/*.
43
+ * @returns {Router}
44
+ */
45
+ export function createRouter() {
46
+ const router = Router()
47
+
48
+ // Middleware CORS para todas las rutas del router
49
+ router.use((req, res, next) => {
50
+ res.setHeader('Access-Control-Allow-Origin', '*')
51
+ next()
52
+ })
53
+
54
+ // ── SSE ────────────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * GET /api/stream
58
+ * Endpoint SSE para recibir actualizaciones en tiempo real.
59
+ */
60
+ router.get('/api/stream', (req, res) => {
61
+ res.setHeader('Content-Type', 'text/event-stream')
62
+ res.setHeader('Cache-Control', 'no-cache')
63
+ res.setHeader('Connection', 'keep-alive')
64
+ res.setHeader('Access-Control-Allow-Origin', '*')
65
+ res.flushHeaders()
66
+
67
+ const clientId = ++_nextClientId
68
+ sseClients.set(clientId, res)
69
+
70
+ // Heartbeat cada 15 segundos
71
+ const heartbeat = setInterval(() => {
72
+ try { res.write('data: {"type":"ping"}\n\n') } catch { /* ignorar */ }
73
+ }, 15_000)
74
+
75
+ req.on('close', () => {
76
+ clearInterval(heartbeat)
77
+ sseClients.delete(clientId)
78
+ })
79
+ })
80
+
81
+ // ── Endpoints REST ─────────────────────────────────────────────────────────
82
+
83
+ /** GET /api/status — estado de conexión del servidor */
84
+ router.get('/api/status', (req, res) => {
85
+ res.json(getStatus())
86
+ })
87
+
88
+ /** GET /api/summary — resumen agregado, acepta query param ?from */
89
+ router.get('/api/summary', (req, res) => {
90
+ res.json(getSummary(req.query.from))
91
+ })
92
+
93
+ /** GET /api/timeseries — serie temporal, acepta ?metric, ?from, ?bucket */
94
+ router.get('/api/timeseries', (req, res) => {
95
+ res.json(getTimeseries(req.query.metric, req.query.from, req.query.bucket))
96
+ })
97
+
98
+ /** GET /api/projects — proyectos con métricas, acepta ?from */
99
+ router.get('/api/projects', (req, res) => {
100
+ res.json(getProjects(req.query.from))
101
+ })
102
+
103
+ /**
104
+ * GET /api/sessions/unlabeled — sesiones sin proyecto asignado.
105
+ * ⚠️ REGISTRAR ANTES de /api/sessions/:sessionId para evitar colisiones.
106
+ */
107
+ router.get('/api/sessions/unlabeled', (req, res) => {
108
+ res.json(getUnlabeledSessions())
109
+ })
110
+
111
+ /** GET /api/sessions — lista de sesiones, acepta ?limit, ?project */
112
+ router.get('/api/sessions', (req, res) => {
113
+ res.json(getSessions({
114
+ limit: req.query.limit ? Number(req.query.limit) : 50,
115
+ project: req.query.project || null
116
+ }))
117
+ })
118
+
119
+ /** GET /api/sessions/:sessionId/events — eventos de una sesión */
120
+ router.get('/api/sessions/:sessionId/events', (req, res) => {
121
+ res.json(getSessionEvents(req.params.sessionId))
122
+ })
123
+
124
+ /** GET /api/events — eventos filtrados, acepta ?limit, ?type, ?project */
125
+ router.get('/api/events', (req, res) => {
126
+ res.json(getEvents({
127
+ limit: req.query.limit ? Number(req.query.limit) : 200,
128
+ type: req.query.type || null,
129
+ project: req.query.project || null
130
+ }))
131
+ })
132
+
133
+ /** GET /api/tools — estadísticas de herramientas, acepta ?from */
134
+ router.get('/api/tools', (req, res) => {
135
+ res.json(getTools(req.query.from))
136
+ })
137
+
138
+ /** GET /api/agents — lista de agentes registrados */
139
+ router.get('/api/agents', (req, res) => {
140
+ res.json(getAgents())
141
+ })
142
+
143
+ /** GET /api/models — estadísticas por modelo, acepta ?from */
144
+ router.get('/api/models', (req, res) => {
145
+ res.json(getModels(req.query.from))
146
+ })
147
+
148
+ /**
149
+ * POST /api/sessions/:sessionId/label
150
+ * Asigna un proyecto a una sesión.
151
+ * Body: { project: string }
152
+ */
153
+ router.post('/api/sessions/:sessionId/label', (req, res) => {
154
+ const { project } = req.body
155
+ if (!project || typeof project !== 'string') {
156
+ return res.status(400).json({ error: 'project requerido' })
157
+ }
158
+ labelSession(req.params.sessionId, project)
159
+ broadcast('label_updated', { sessionId: req.params.sessionId, project })
160
+ res.json({ ok: true })
161
+ })
162
+
163
+ /**
164
+ * POST /api/sessions/:sessionId/ignore
165
+ * Marca una sesión como ignorada (no aparecerá en notificaciones ni en métricas).
166
+ */
167
+ router.post('/api/sessions/:sessionId/ignore', (req, res) => {
168
+ ignoreSession(req.params.sessionId)
169
+ res.json({ ok: true })
170
+ })
171
+
172
+ /** POST /api/reset — resetea todo el estado en memoria */
173
+ router.post('/api/reset', (req, res) => {
174
+ reset()
175
+ res.json({ ok: true })
176
+ })
177
+
178
+ return router
179
+ }
180
+
@@ -0,0 +1,193 @@
1
+ /**
2
+ * otlp-parser.js
3
+ *
4
+ * Parsea cuerpos JSON OTLP y devuelve arrays de objetos normalizados.
5
+ * Sin estado. Sin efectos secundarios. Funciones puras.
6
+ *
7
+ * Claves de atributos se preservan verbatim (con puntos), ej: "session.id".
8
+ */
9
+
10
+ /**
11
+ * Convierte el array de atributos OTLP a un objeto plano { key: value }.
12
+ * Los intValue pueden llegar como string — se convierten a Number.
13
+ *
14
+ * @param {Array|undefined} attrs - Array de { key, value: { stringValue|intValue|doubleValue|boolValue } }
15
+ * @returns {Object} Objeto plano con las claves originales
16
+ */
17
+ export function extractAttributes(attrs) {
18
+ if (!attrs || attrs.length === 0) return {}
19
+
20
+ const result = {}
21
+ for (const attr of attrs) {
22
+ const { key, value } = attr
23
+ if (!key || !value) continue
24
+
25
+ if ('stringValue' in value) {
26
+ result[key] = value.stringValue
27
+ } else if ('intValue' in value) {
28
+ // intValue puede llegar como string desde JSON
29
+ result[key] = Number(value.intValue)
30
+ } else if ('doubleValue' in value) {
31
+ result[key] = value.doubleValue
32
+ } else if ('boolValue' in value) {
33
+ result[key] = value.boolValue
34
+ }
35
+ }
36
+ return result
37
+ }
38
+
39
+ /**
40
+ * Parsea el cuerpo OTLP de métricas.
41
+ * Soporta tipos: sum, gauge, histogram.
42
+ *
43
+ * @param {Object} body - Cuerpo JSON del POST /v1/metrics
44
+ * @returns {Array<{name, value, timestamp, labels}>}
45
+ */
46
+ export function parseMetrics(body) {
47
+ try {
48
+ const result = []
49
+ const resourceMetrics = body?.resourceMetrics ?? []
50
+
51
+ for (const rm of resourceMetrics) {
52
+ // Extraer atributos del recurso (service.name, session.id, etc.)
53
+ const resourceAttrs = extractAttributes(rm.resource?.attributes)
54
+
55
+ for (const sm of rm.scopeMetrics ?? []) {
56
+ for (const metric of sm.metrics ?? []) {
57
+ const metricName = metric.name
58
+
59
+ // Determinar el array de dataPoints según el tipo de métrica
60
+ let dataPoints = []
61
+ if (metric.sum?.dataPoints) {
62
+ dataPoints = metric.sum.dataPoints
63
+ } else if (metric.gauge?.dataPoints) {
64
+ dataPoints = metric.gauge.dataPoints
65
+ } else if (metric.histogram?.dataPoints) {
66
+ dataPoints = metric.histogram.dataPoints
67
+ }
68
+
69
+ for (const dp of dataPoints) {
70
+ // Valor: preferir asInt → asDouble → sum (histogram) → 0
71
+ let value = 0
72
+ if ('asInt' in dp) {
73
+ value = Number(dp.asInt)
74
+ } else if ('asDouble' in dp) {
75
+ value = dp.asDouble
76
+ } else if ('sum' in dp) {
77
+ value = dp.sum
78
+ }
79
+
80
+ // Timestamp en ms (timeUnixNano / 1_000_000)
81
+ const timestamp = dp.timeUnixNano
82
+ ? Number(dp.timeUnixNano) / 1_000_000
83
+ : Date.now()
84
+
85
+ // Fusionar atributos de recurso + atributos del dataPoint
86
+ const labels = {
87
+ ...resourceAttrs,
88
+ ...extractAttributes(dp.attributes)
89
+ }
90
+
91
+ result.push({ name: metricName, value, timestamp, labels })
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ return result
98
+ } catch (err) {
99
+ console.error('[otlp-parser] Error parseando métricas:', err.message)
100
+ return []
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Parsea el cuerpo OTLP de logs (eventos).
106
+ *
107
+ * @param {Object} body - Cuerpo JSON del POST /v1/logs
108
+ * @returns {Array<{eventName, timestamp, severity, attributes}>}
109
+ */
110
+ export function parseEvents(body) {
111
+ try {
112
+ const result = []
113
+ const resourceLogs = body?.resourceLogs ?? []
114
+
115
+ for (const rl of resourceLogs) {
116
+ const resourceAttrs = extractAttributes(rl.resource?.attributes)
117
+
118
+ for (const sl of rl.scopeLogs ?? []) {
119
+ for (const lr of sl.logRecords ?? []) {
120
+ const dpAttrs = extractAttributes(lr.attributes)
121
+
122
+ // eventName: buscar en attributes["event.name"] → lr.body.stringValue → "unknown"
123
+ const eventName = dpAttrs['event.name'] ?? lr.body?.stringValue ?? 'unknown'
124
+
125
+ const timestamp = lr.timeUnixNano
126
+ ? Number(lr.timeUnixNano) / 1_000_000
127
+ : Date.now()
128
+
129
+ const severity = lr.severityText ?? 'INFO'
130
+
131
+ const attributes = {
132
+ ...resourceAttrs,
133
+ ...dpAttrs
134
+ }
135
+
136
+ result.push({ eventName, timestamp, severity, attributes })
137
+ }
138
+ }
139
+ }
140
+
141
+ return result
142
+ } catch (err) {
143
+ console.error('[otlp-parser] Error parseando eventos:', err.message)
144
+ return []
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Parsea el cuerpo OTLP de trazas (spans).
150
+ *
151
+ * @param {Object} body - Cuerpo JSON del POST /v1/traces
152
+ * @returns {Array<{spanId, traceId, parentSpanId, name, startTime, endTime, attributes, status}>}
153
+ */
154
+ export function parseTraces(body) {
155
+ try {
156
+ const result = []
157
+ const resourceSpans = body?.resourceSpans ?? []
158
+
159
+ for (const rs of resourceSpans) {
160
+ const resourceAttrs = extractAttributes(rs.resource?.attributes)
161
+
162
+ for (const ss of rs.scopeSpans ?? []) {
163
+ for (const span of ss.spans ?? []) {
164
+ const attributes = {
165
+ ...resourceAttrs,
166
+ ...extractAttributes(span.attributes)
167
+ }
168
+
169
+ result.push({
170
+ spanId: span.spanId,
171
+ traceId: span.traceId,
172
+ // parentSpanId es null si está ausente o es string vacío
173
+ parentSpanId: span.parentSpanId || null,
174
+ name: span.name,
175
+ startTime: span.startTimeUnixNano
176
+ ? Number(span.startTimeUnixNano) / 1_000_000
177
+ : 0,
178
+ endTime: span.endTimeUnixNano
179
+ ? Number(span.endTimeUnixNano) / 1_000_000
180
+ : 0,
181
+ attributes,
182
+ status: span.status ?? {}
183
+ })
184
+ }
185
+ }
186
+ }
187
+
188
+ return result
189
+ } catch (err) {
190
+ console.error('[otlp-parser] Error parseando trazas:', err.message)
191
+ return []
192
+ }
193
+ }
package/src/server.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * server.js
3
+ *
4
+ * Express completo en un único puerto 1337:
5
+ * - Endpoints OTLP (/v1/*)
6
+ * - API REST + SSE (/api/*)
7
+ * - Archivos estáticos (dist/)
8
+ * - SPA fallback (index.html)
9
+ *
10
+ * Export:
11
+ * startServer({ port }) → Promise<{ app, server, autoSaveInterval }>
12
+ */
13
+
14
+ import express from 'express'
15
+ import path from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { parseMetrics, parseEvents, parseTraces } from './otlp-parser.js'
18
+ import { processMetric, processEvent, processTrace, loadFromDisk, startAutoSave, saveSync } from './store.js'
19
+ import { createRouter, broadcast } from './api-routes.js'
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
22
+
23
+ /**
24
+ * Arranca el servidor Express.
25
+ * @param {{ port?: number }} options
26
+ * @returns {Promise<{ app: express.Application, server: import('node:http').Server, autoSaveInterval: NodeJS.Timeout }>}
27
+ */
28
+ export async function startServer({ port = 1337 } = {}) {
29
+ loadFromDisk()
30
+
31
+ const app = express()
32
+ app.use(express.json({ limit: '10mb' }))
33
+
34
+ // ── Endpoints OTLP ──────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * POST /v1/metrics — recibe métricas en formato OTLP.
38
+ * Siempre responde 200 { partialSuccess: {} } para que Claude Code no reintente.
39
+ */
40
+ app.post('/v1/metrics', (req, res) => {
41
+ const points = parseMetrics(req.body)
42
+ for (const point of points) {
43
+ processMetric(point)
44
+ }
45
+ broadcast('metrics', { count: points.length })
46
+ res.json({ partialSuccess: {} })
47
+ })
48
+
49
+ /**
50
+ * POST /v1/logs — recibe eventos/logs en formato OTLP.
51
+ * Siempre responde 200 { partialSuccess: {} }.
52
+ */
53
+ app.post('/v1/logs', (req, res) => {
54
+ const events = parseEvents(req.body)
55
+ for (const ev of events) {
56
+ const stored = processEvent(ev)
57
+ broadcast('event', stored)
58
+ }
59
+ res.json({ partialSuccess: {} })
60
+ })
61
+
62
+ /**
63
+ * POST /v1/traces — recibe trazas en formato OTLP.
64
+ * Siempre responde 200 { partialSuccess: {} }.
65
+ */
66
+ app.post('/v1/traces', (req, res) => {
67
+ const spans = parseTraces(req.body)
68
+ for (const span of spans) {
69
+ processTrace(span)
70
+ }
71
+ broadcast('trace', { count: spans.length })
72
+ res.json({ partialSuccess: {} })
73
+ })
74
+
75
+ /**
76
+ * Middleware de error para endpoints OTLP /v1/*.
77
+ * Si express.json() rechaza un body inválido (JSON malformado),
78
+ * respondemos igualmente 200 { partialSuccess: {} } para que Claude Code no reintente.
79
+ */
80
+ // eslint-disable-next-line no-unused-vars
81
+ app.use('/v1', (err, req, res, next) => {
82
+ res.json({ partialSuccess: {} })
83
+ })
84
+
85
+ // ── API REST + SSE ──────────────────────────────────────────────────────────
86
+ app.use(createRouter())
87
+
88
+ // ── Archivos estáticos (web compilada) ──────────────────────────────────────
89
+ // dist/ está en la raíz del proyecto (un nivel arriba de src/)
90
+ const distPath = path.join(__dirname, '../dist')
91
+ app.use(express.static(distPath))
92
+
93
+ // SPA fallback: cualquier ruta no-API sirve index.html
94
+ app.get(/^(?!\/api\/)/, (req, res) => {
95
+ const indexPath = path.join(distPath, 'index.html')
96
+ res.sendFile(indexPath, (err) => {
97
+ if (err) res.status(404).send('Dashboard no disponible. Ejecuta: npm run build')
98
+ })
99
+ })
100
+
101
+ // ── Autosave + señales de apagado ───────────────────────────────────────────
102
+ const autoSaveInterval = startAutoSave()
103
+
104
+ const shutdown = () => {
105
+ clearInterval(autoSaveInterval)
106
+ saveSync()
107
+ process.exit(0)
108
+ }
109
+ process.on('SIGINT', shutdown)
110
+ process.on('SIGTERM', shutdown)
111
+
112
+ // ── Arrancar servidor ───────────────────────────────────────────────────────
113
+ return new Promise((resolve) => {
114
+ const server = app.listen(port, () => {
115
+ resolve({ app, server, autoSaveInterval })
116
+ })
117
+ })
118
+ }