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/dist/index.html
ADDED
|
@@ -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
|
+
}
|