llmflow 0.3.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.
- package/README.md +142 -0
- package/bin/llmflow.js +91 -0
- package/db.js +857 -0
- package/logger.js +122 -0
- package/otlp-export.js +564 -0
- package/otlp-logs.js +238 -0
- package/otlp-metrics.js +300 -0
- package/otlp.js +398 -0
- package/package.json +62 -0
- package/pricing.fallback.json +58 -0
- package/pricing.js +154 -0
- package/providers/anthropic.js +195 -0
- package/providers/azure.js +159 -0
- package/providers/base.js +145 -0
- package/providers/cohere.js +225 -0
- package/providers/gemini.js +278 -0
- package/providers/index.js +130 -0
- package/providers/ollama.js +36 -0
- package/providers/openai-compatible.js +77 -0
- package/providers/openai.js +217 -0
- package/providers/passthrough.js +573 -0
- package/public/app.js +1484 -0
- package/public/index.html +367 -0
- package/public/style.css +1152 -0
- package/server.js +1222 -0
package/db.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const Database = require('better-sqlite3');
|
|
5
|
+
|
|
6
|
+
const DATA_DIR = process.env.DATA_DIR || path.join(os.homedir(), '.llmflow');
|
|
7
|
+
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, 'data.db');
|
|
8
|
+
const MAX_TRACES = parseInt(process.env.MAX_TRACES || '10000', 10);
|
|
9
|
+
const MAX_LOGS = parseInt(process.env.MAX_LOGS || '100000', 10);
|
|
10
|
+
const MAX_METRICS = parseInt(process.env.MAX_METRICS || '1000000', 10);
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
13
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const db = new Database(DB_PATH);
|
|
17
|
+
|
|
18
|
+
// Helper to add columns if they don't exist (simple migration)
|
|
19
|
+
function ensureColumn(name, definition) {
|
|
20
|
+
const info = db.prepare('PRAGMA table_info(traces)').all();
|
|
21
|
+
if (!info.find(c => c.name === name)) {
|
|
22
|
+
db.exec(`ALTER TABLE traces ADD COLUMN ${name} ${definition}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function initSchema() {
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
timestamp INTEGER NOT NULL,
|
|
31
|
+
duration_ms INTEGER,
|
|
32
|
+
provider TEXT DEFAULT 'openai',
|
|
33
|
+
model TEXT,
|
|
34
|
+
prompt_tokens INTEGER DEFAULT 0,
|
|
35
|
+
completion_tokens INTEGER DEFAULT 0,
|
|
36
|
+
total_tokens INTEGER DEFAULT 0,
|
|
37
|
+
estimated_cost REAL DEFAULT 0,
|
|
38
|
+
status INTEGER,
|
|
39
|
+
error TEXT,
|
|
40
|
+
request_method TEXT,
|
|
41
|
+
request_path TEXT,
|
|
42
|
+
request_headers TEXT,
|
|
43
|
+
request_body TEXT,
|
|
44
|
+
response_status INTEGER,
|
|
45
|
+
response_headers TEXT,
|
|
46
|
+
response_body TEXT,
|
|
47
|
+
tags TEXT,
|
|
48
|
+
trace_id TEXT,
|
|
49
|
+
parent_id TEXT
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_traces_timestamp ON traces(timestamp DESC);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_traces_model ON traces(model);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_traces_trace_id ON traces(trace_id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_traces_status ON traces(status);
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
// Span-specific columns (v0.2+)
|
|
59
|
+
ensureColumn('span_type', "TEXT DEFAULT 'llm'");
|
|
60
|
+
ensureColumn('span_name', 'TEXT');
|
|
61
|
+
ensureColumn('input', 'TEXT');
|
|
62
|
+
ensureColumn('output', 'TEXT');
|
|
63
|
+
ensureColumn('attributes', 'TEXT');
|
|
64
|
+
ensureColumn('service_name', 'TEXT');
|
|
65
|
+
|
|
66
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_traces_parent_id ON traces(parent_id)');
|
|
67
|
+
|
|
68
|
+
db.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS stats_cache (
|
|
70
|
+
key TEXT PRIMARY KEY,
|
|
71
|
+
value TEXT,
|
|
72
|
+
updated_at INTEGER
|
|
73
|
+
);
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
// Logs table for OTLP logs ingestion (v0.2.1+)
|
|
77
|
+
db.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
timestamp INTEGER NOT NULL,
|
|
81
|
+
observed_timestamp INTEGER,
|
|
82
|
+
|
|
83
|
+
-- Severity
|
|
84
|
+
severity_number INTEGER,
|
|
85
|
+
severity_text TEXT,
|
|
86
|
+
|
|
87
|
+
-- Content
|
|
88
|
+
body TEXT,
|
|
89
|
+
|
|
90
|
+
-- Context
|
|
91
|
+
trace_id TEXT,
|
|
92
|
+
span_id TEXT,
|
|
93
|
+
|
|
94
|
+
-- Classification
|
|
95
|
+
event_name TEXT,
|
|
96
|
+
service_name TEXT,
|
|
97
|
+
scope_name TEXT,
|
|
98
|
+
|
|
99
|
+
-- Structured data
|
|
100
|
+
attributes TEXT,
|
|
101
|
+
resource_attributes TEXT
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_logs_trace_id ON logs(trace_id);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_logs_event_name ON logs(event_name);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_logs_service_name ON logs(service_name);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_logs_severity ON logs(severity_number);
|
|
109
|
+
`);
|
|
110
|
+
|
|
111
|
+
// Metrics table for OTLP metrics ingestion (v0.2.2+)
|
|
112
|
+
db.exec(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
timestamp INTEGER NOT NULL,
|
|
116
|
+
|
|
117
|
+
-- Metric identification
|
|
118
|
+
name TEXT NOT NULL,
|
|
119
|
+
description TEXT,
|
|
120
|
+
unit TEXT,
|
|
121
|
+
metric_type TEXT,
|
|
122
|
+
|
|
123
|
+
-- Value (for simple metrics)
|
|
124
|
+
value_int INTEGER,
|
|
125
|
+
value_double REAL,
|
|
126
|
+
|
|
127
|
+
-- Histogram buckets (JSON for complex data)
|
|
128
|
+
histogram_data TEXT,
|
|
129
|
+
|
|
130
|
+
-- Context
|
|
131
|
+
service_name TEXT,
|
|
132
|
+
scope_name TEXT,
|
|
133
|
+
|
|
134
|
+
-- Dimensions
|
|
135
|
+
attributes TEXT,
|
|
136
|
+
resource_attributes TEXT
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp DESC);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_name ON metrics(name);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_service_name ON metrics(service_name);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_type ON metrics(metric_type);
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
initSchema();
|
|
147
|
+
|
|
148
|
+
const insertTraceStmt = db.prepare(`
|
|
149
|
+
INSERT INTO traces (
|
|
150
|
+
id, timestamp, duration_ms,
|
|
151
|
+
provider, model,
|
|
152
|
+
prompt_tokens, completion_tokens, total_tokens,
|
|
153
|
+
estimated_cost, status, error,
|
|
154
|
+
request_method, request_path, request_headers, request_body,
|
|
155
|
+
response_status, response_headers, response_body,
|
|
156
|
+
tags, trace_id, parent_id,
|
|
157
|
+
span_type, span_name, input, output, attributes, service_name
|
|
158
|
+
) VALUES (
|
|
159
|
+
@id, @timestamp, @duration_ms,
|
|
160
|
+
@provider, @model,
|
|
161
|
+
@prompt_tokens, @completion_tokens, @total_tokens,
|
|
162
|
+
@estimated_cost, @status, @error,
|
|
163
|
+
@request_method, @request_path, @request_headers, @request_body,
|
|
164
|
+
@response_status, @response_headers, @response_body,
|
|
165
|
+
@tags, @trace_id, @parent_id,
|
|
166
|
+
@span_type, @span_name, @input, @output, @attributes, @service_name
|
|
167
|
+
)
|
|
168
|
+
`);
|
|
169
|
+
|
|
170
|
+
const deleteOverflowStmt = db.prepare(`
|
|
171
|
+
DELETE FROM traces
|
|
172
|
+
WHERE id NOT IN (
|
|
173
|
+
SELECT id FROM traces ORDER BY timestamp DESC LIMIT ?
|
|
174
|
+
)
|
|
175
|
+
`);
|
|
176
|
+
|
|
177
|
+
const insertLogStmt = db.prepare(`
|
|
178
|
+
INSERT INTO logs (
|
|
179
|
+
id, timestamp, observed_timestamp,
|
|
180
|
+
severity_number, severity_text,
|
|
181
|
+
body, trace_id, span_id,
|
|
182
|
+
event_name, service_name, scope_name,
|
|
183
|
+
attributes, resource_attributes
|
|
184
|
+
) VALUES (
|
|
185
|
+
@id, @timestamp, @observed_timestamp,
|
|
186
|
+
@severity_number, @severity_text,
|
|
187
|
+
@body, @trace_id, @span_id,
|
|
188
|
+
@event_name, @service_name, @scope_name,
|
|
189
|
+
@attributes, @resource_attributes
|
|
190
|
+
)
|
|
191
|
+
`);
|
|
192
|
+
|
|
193
|
+
const deleteLogOverflowStmt = db.prepare(`
|
|
194
|
+
DELETE FROM logs
|
|
195
|
+
WHERE id NOT IN (
|
|
196
|
+
SELECT id FROM logs ORDER BY timestamp DESC LIMIT ?
|
|
197
|
+
)
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
const insertMetricStmt = db.prepare(`
|
|
201
|
+
INSERT INTO metrics (
|
|
202
|
+
id, timestamp,
|
|
203
|
+
name, description, unit, metric_type,
|
|
204
|
+
value_int, value_double, histogram_data,
|
|
205
|
+
service_name, scope_name,
|
|
206
|
+
attributes, resource_attributes
|
|
207
|
+
) VALUES (
|
|
208
|
+
@id, @timestamp,
|
|
209
|
+
@name, @description, @unit, @metric_type,
|
|
210
|
+
@value_int, @value_double, @histogram_data,
|
|
211
|
+
@service_name, @scope_name,
|
|
212
|
+
@attributes, @resource_attributes
|
|
213
|
+
)
|
|
214
|
+
`);
|
|
215
|
+
|
|
216
|
+
const deleteMetricOverflowStmt = db.prepare(`
|
|
217
|
+
DELETE FROM metrics
|
|
218
|
+
WHERE id NOT IN (
|
|
219
|
+
SELECT id FROM metrics ORDER BY timestamp DESC LIMIT ?
|
|
220
|
+
)
|
|
221
|
+
`);
|
|
222
|
+
|
|
223
|
+
// Hook for real-time updates
|
|
224
|
+
let onInsertTrace = null;
|
|
225
|
+
let onInsertLog = null;
|
|
226
|
+
let onInsertMetric = null;
|
|
227
|
+
|
|
228
|
+
function setInsertTraceHook(fn) {
|
|
229
|
+
onInsertTrace = fn;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function setInsertLogHook(fn) {
|
|
233
|
+
onInsertLog = fn;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setInsertMetricHook(fn) {
|
|
237
|
+
onInsertMetric = fn;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function insertTrace(trace) {
|
|
241
|
+
insertTraceStmt.run({
|
|
242
|
+
id: trace.id,
|
|
243
|
+
timestamp: trace.timestamp,
|
|
244
|
+
duration_ms: trace.duration_ms || null,
|
|
245
|
+
provider: trace.provider || null,
|
|
246
|
+
model: trace.model || null,
|
|
247
|
+
prompt_tokens: trace.prompt_tokens || 0,
|
|
248
|
+
completion_tokens: trace.completion_tokens || 0,
|
|
249
|
+
total_tokens: trace.total_tokens || 0,
|
|
250
|
+
estimated_cost: trace.estimated_cost || 0,
|
|
251
|
+
status: trace.status || null,
|
|
252
|
+
error: trace.error || null,
|
|
253
|
+
request_method: trace.request_method || null,
|
|
254
|
+
request_path: trace.request_path || null,
|
|
255
|
+
request_headers: JSON.stringify(trace.request_headers || {}),
|
|
256
|
+
request_body: JSON.stringify(trace.request_body || {}),
|
|
257
|
+
response_status: trace.response_status || null,
|
|
258
|
+
response_headers: JSON.stringify(trace.response_headers || {}),
|
|
259
|
+
response_body: JSON.stringify(trace.response_body || {}),
|
|
260
|
+
tags: JSON.stringify(trace.tags || []),
|
|
261
|
+
trace_id: trace.trace_id || trace.id,
|
|
262
|
+
parent_id: trace.parent_id || null,
|
|
263
|
+
span_type: trace.span_type || 'llm',
|
|
264
|
+
span_name: trace.span_name || null,
|
|
265
|
+
input: JSON.stringify(trace.input || null),
|
|
266
|
+
output: JSON.stringify(trace.output || null),
|
|
267
|
+
attributes: JSON.stringify(trace.attributes || {}),
|
|
268
|
+
service_name: trace.service_name || null
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const count = getTraceCount();
|
|
272
|
+
if (count > MAX_TRACES) {
|
|
273
|
+
deleteOverflowStmt.run(MAX_TRACES);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Trigger hook for real-time updates
|
|
277
|
+
if (onInsertTrace) {
|
|
278
|
+
const summary = {
|
|
279
|
+
id: trace.id,
|
|
280
|
+
timestamp: trace.timestamp,
|
|
281
|
+
duration_ms: trace.duration_ms || null,
|
|
282
|
+
model: trace.model || null,
|
|
283
|
+
total_tokens: trace.total_tokens || 0,
|
|
284
|
+
estimated_cost: trace.estimated_cost || 0,
|
|
285
|
+
status: trace.status || null,
|
|
286
|
+
trace_id: trace.trace_id || trace.id,
|
|
287
|
+
parent_id: trace.parent_id || null,
|
|
288
|
+
span_type: trace.span_type || 'llm',
|
|
289
|
+
span_name: trace.span_name || null,
|
|
290
|
+
service_name: trace.service_name || null
|
|
291
|
+
};
|
|
292
|
+
try {
|
|
293
|
+
onInsertTrace(summary);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// Don't let hook errors break insertion
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function getTraces({ limit = 50, offset = 0, filters = {} } = {}) {
|
|
301
|
+
const where = [];
|
|
302
|
+
const params = {};
|
|
303
|
+
|
|
304
|
+
if (filters.model) {
|
|
305
|
+
where.push('model = @model');
|
|
306
|
+
params.model = filters.model;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (filters.status) {
|
|
310
|
+
if (filters.status === 'error') {
|
|
311
|
+
where.push('status >= 400');
|
|
312
|
+
} else if (filters.status === 'success') {
|
|
313
|
+
where.push('status < 400');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (filters.q) {
|
|
318
|
+
where.push('(request_body LIKE @q OR response_body LIKE @q OR input LIKE @q OR output LIKE @q)');
|
|
319
|
+
params.q = `%${filters.q}%`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (filters.date_from) {
|
|
323
|
+
where.push('timestamp >= @date_from');
|
|
324
|
+
params.date_from = filters.date_from;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (filters.date_to) {
|
|
328
|
+
where.push('timestamp <= @date_to');
|
|
329
|
+
params.date_to = filters.date_to;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (filters.cost_min != null) {
|
|
333
|
+
where.push('estimated_cost >= @cost_min');
|
|
334
|
+
params.cost_min = filters.cost_min;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (filters.cost_max != null) {
|
|
338
|
+
where.push('estimated_cost <= @cost_max');
|
|
339
|
+
params.cost_max = filters.cost_max;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (filters.span_type) {
|
|
343
|
+
where.push('span_type = @span_type');
|
|
344
|
+
params.span_type = filters.span_type;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
348
|
+
const stmt = db.prepare(`
|
|
349
|
+
SELECT
|
|
350
|
+
id, timestamp, duration_ms, provider, model,
|
|
351
|
+
prompt_tokens, completion_tokens, total_tokens,
|
|
352
|
+
estimated_cost, status, error, trace_id, parent_id,
|
|
353
|
+
span_type, span_name, service_name
|
|
354
|
+
FROM traces
|
|
355
|
+
${whereSql}
|
|
356
|
+
ORDER BY timestamp DESC
|
|
357
|
+
LIMIT @limit OFFSET @offset
|
|
358
|
+
`);
|
|
359
|
+
|
|
360
|
+
return stmt.all({ ...params, limit, offset });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function getSpansByTraceId(traceId) {
|
|
364
|
+
return db.prepare(`
|
|
365
|
+
SELECT *
|
|
366
|
+
FROM traces
|
|
367
|
+
WHERE trace_id = ?
|
|
368
|
+
ORDER BY timestamp ASC
|
|
369
|
+
`).all(traceId);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getTraceById(id) {
|
|
373
|
+
return db.prepare('SELECT * FROM traces WHERE id = ?').get(id);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function getStats() {
|
|
377
|
+
const row = db.prepare(`
|
|
378
|
+
SELECT
|
|
379
|
+
COUNT(*) as total_requests,
|
|
380
|
+
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
|
381
|
+
COALESCE(SUM(estimated_cost), 0) as total_cost,
|
|
382
|
+
COALESCE(SUM(duration_ms), 0) as total_duration,
|
|
383
|
+
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) as error_count
|
|
384
|
+
FROM traces
|
|
385
|
+
`).get();
|
|
386
|
+
|
|
387
|
+
const models = db.prepare(`
|
|
388
|
+
SELECT
|
|
389
|
+
model,
|
|
390
|
+
COUNT(*) as count,
|
|
391
|
+
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
392
|
+
COALESCE(SUM(estimated_cost), 0) as cost
|
|
393
|
+
FROM traces
|
|
394
|
+
GROUP BY model
|
|
395
|
+
ORDER BY count DESC
|
|
396
|
+
`).all();
|
|
397
|
+
|
|
398
|
+
const avg_duration = row.total_requests > 0
|
|
399
|
+
? row.total_duration / row.total_requests
|
|
400
|
+
: 0;
|
|
401
|
+
|
|
402
|
+
return { ...row, avg_duration, models };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getTraceCount() {
|
|
406
|
+
return db.prepare('SELECT COUNT(*) as cnt FROM traces').get().cnt;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getDistinctModels() {
|
|
410
|
+
return db.prepare('SELECT DISTINCT model FROM traces WHERE model IS NOT NULL ORDER BY model').all()
|
|
411
|
+
.map(r => r.model);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ==================== Logs Functions ====================
|
|
415
|
+
|
|
416
|
+
function insertLog(log) {
|
|
417
|
+
insertLogStmt.run({
|
|
418
|
+
id: log.id,
|
|
419
|
+
timestamp: log.timestamp,
|
|
420
|
+
observed_timestamp: log.observed_timestamp || null,
|
|
421
|
+
severity_number: log.severity_number || null,
|
|
422
|
+
severity_text: log.severity_text || null,
|
|
423
|
+
body: typeof log.body === 'string' ? log.body : JSON.stringify(log.body || null),
|
|
424
|
+
trace_id: log.trace_id || null,
|
|
425
|
+
span_id: log.span_id || null,
|
|
426
|
+
event_name: log.event_name || null,
|
|
427
|
+
service_name: log.service_name || null,
|
|
428
|
+
scope_name: log.scope_name || null,
|
|
429
|
+
attributes: JSON.stringify(log.attributes || {}),
|
|
430
|
+
resource_attributes: JSON.stringify(log.resource_attributes || {})
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const count = getLogCount();
|
|
434
|
+
if (count > MAX_LOGS) {
|
|
435
|
+
deleteLogOverflowStmt.run(MAX_LOGS);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (onInsertLog) {
|
|
439
|
+
const summary = {
|
|
440
|
+
id: log.id,
|
|
441
|
+
timestamp: log.timestamp,
|
|
442
|
+
severity_text: log.severity_text || null,
|
|
443
|
+
event_name: log.event_name || null,
|
|
444
|
+
service_name: log.service_name || null,
|
|
445
|
+
trace_id: log.trace_id || null,
|
|
446
|
+
body: typeof log.body === 'string' ? log.body.slice(0, 200) : null
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
onInsertLog(summary);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
// Don't let hook errors break insertion
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function getLogs({ limit = 50, offset = 0, filters = {} } = {}) {
|
|
457
|
+
const where = [];
|
|
458
|
+
const params = {};
|
|
459
|
+
|
|
460
|
+
if (filters.service_name) {
|
|
461
|
+
where.push('service_name = @service_name');
|
|
462
|
+
params.service_name = filters.service_name;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (filters.event_name) {
|
|
466
|
+
where.push('event_name = @event_name');
|
|
467
|
+
params.event_name = filters.event_name;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (filters.trace_id) {
|
|
471
|
+
where.push('trace_id = @trace_id');
|
|
472
|
+
params.trace_id = filters.trace_id;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (filters.severity_min != null) {
|
|
476
|
+
where.push('severity_number >= @severity_min');
|
|
477
|
+
params.severity_min = filters.severity_min;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (filters.date_from) {
|
|
481
|
+
where.push('timestamp >= @date_from');
|
|
482
|
+
params.date_from = filters.date_from;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (filters.date_to) {
|
|
486
|
+
where.push('timestamp <= @date_to');
|
|
487
|
+
params.date_to = filters.date_to;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (filters.q) {
|
|
491
|
+
where.push('(body LIKE @q OR attributes LIKE @q)');
|
|
492
|
+
params.q = `%${filters.q}%`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
496
|
+
const stmt = db.prepare(`
|
|
497
|
+
SELECT
|
|
498
|
+
id, timestamp, observed_timestamp,
|
|
499
|
+
severity_number, severity_text,
|
|
500
|
+
body, trace_id, span_id,
|
|
501
|
+
event_name, service_name, scope_name,
|
|
502
|
+
attributes, resource_attributes
|
|
503
|
+
FROM logs
|
|
504
|
+
${whereSql}
|
|
505
|
+
ORDER BY timestamp DESC
|
|
506
|
+
LIMIT @limit OFFSET @offset
|
|
507
|
+
`);
|
|
508
|
+
|
|
509
|
+
return stmt.all({ ...params, limit, offset });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function getLogById(id) {
|
|
513
|
+
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id);
|
|
514
|
+
if (log) {
|
|
515
|
+
log.attributes = JSON.parse(log.attributes || '{}');
|
|
516
|
+
log.resource_attributes = JSON.parse(log.resource_attributes || '{}');
|
|
517
|
+
}
|
|
518
|
+
return log;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function getLogsByTraceId(traceId) {
|
|
522
|
+
return db.prepare(`
|
|
523
|
+
SELECT *
|
|
524
|
+
FROM logs
|
|
525
|
+
WHERE trace_id = ?
|
|
526
|
+
ORDER BY timestamp ASC
|
|
527
|
+
`).all(traceId);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function getLogCount(filters = {}) {
|
|
531
|
+
if (Object.keys(filters).length === 0) {
|
|
532
|
+
return db.prepare('SELECT COUNT(*) as cnt FROM logs').get().cnt;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const where = [];
|
|
536
|
+
const params = {};
|
|
537
|
+
|
|
538
|
+
if (filters.service_name) {
|
|
539
|
+
where.push('service_name = @service_name');
|
|
540
|
+
params.service_name = filters.service_name;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (filters.event_name) {
|
|
544
|
+
where.push('event_name = @event_name');
|
|
545
|
+
params.event_name = filters.event_name;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
549
|
+
return db.prepare(`SELECT COUNT(*) as cnt FROM logs ${whereSql}`).get(params).cnt;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function getDistinctEventNames() {
|
|
553
|
+
return db.prepare('SELECT DISTINCT event_name FROM logs WHERE event_name IS NOT NULL ORDER BY event_name').all()
|
|
554
|
+
.map(r => r.event_name);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function getDistinctLogServices() {
|
|
558
|
+
return db.prepare('SELECT DISTINCT service_name FROM logs WHERE service_name IS NOT NULL ORDER BY service_name').all()
|
|
559
|
+
.map(r => r.service_name);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ==================== Metrics Functions ====================
|
|
563
|
+
|
|
564
|
+
function insertMetric(metric) {
|
|
565
|
+
insertMetricStmt.run({
|
|
566
|
+
id: metric.id,
|
|
567
|
+
timestamp: metric.timestamp,
|
|
568
|
+
name: metric.name,
|
|
569
|
+
description: metric.description || null,
|
|
570
|
+
unit: metric.unit || null,
|
|
571
|
+
metric_type: metric.metric_type || 'gauge',
|
|
572
|
+
value_int: metric.value_int != null ? metric.value_int : null,
|
|
573
|
+
value_double: metric.value_double != null ? metric.value_double : null,
|
|
574
|
+
histogram_data: metric.histogram_data ? JSON.stringify(metric.histogram_data) : null,
|
|
575
|
+
service_name: metric.service_name || null,
|
|
576
|
+
scope_name: metric.scope_name || null,
|
|
577
|
+
attributes: JSON.stringify(metric.attributes || {}),
|
|
578
|
+
resource_attributes: JSON.stringify(metric.resource_attributes || {})
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const count = getMetricCount();
|
|
582
|
+
if (count > MAX_METRICS) {
|
|
583
|
+
deleteMetricOverflowStmt.run(MAX_METRICS);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (onInsertMetric) {
|
|
587
|
+
const summary = {
|
|
588
|
+
id: metric.id,
|
|
589
|
+
timestamp: metric.timestamp,
|
|
590
|
+
name: metric.name,
|
|
591
|
+
metric_type: metric.metric_type || 'gauge',
|
|
592
|
+
value_int: metric.value_int,
|
|
593
|
+
value_double: metric.value_double,
|
|
594
|
+
service_name: metric.service_name || null
|
|
595
|
+
};
|
|
596
|
+
try {
|
|
597
|
+
onInsertMetric(summary);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
// Don't let hook errors break insertion
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function getMetrics({ limit = 50, offset = 0, filters = {} } = {}) {
|
|
605
|
+
const where = [];
|
|
606
|
+
const params = {};
|
|
607
|
+
|
|
608
|
+
if (filters.name) {
|
|
609
|
+
where.push('name = @name');
|
|
610
|
+
params.name = filters.name;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (filters.service_name) {
|
|
614
|
+
where.push('service_name = @service_name');
|
|
615
|
+
params.service_name = filters.service_name;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (filters.metric_type) {
|
|
619
|
+
where.push('metric_type = @metric_type');
|
|
620
|
+
params.metric_type = filters.metric_type;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (filters.date_from) {
|
|
624
|
+
where.push('timestamp >= @date_from');
|
|
625
|
+
params.date_from = filters.date_from;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (filters.date_to) {
|
|
629
|
+
where.push('timestamp <= @date_to');
|
|
630
|
+
params.date_to = filters.date_to;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
634
|
+
const stmt = db.prepare(`
|
|
635
|
+
SELECT
|
|
636
|
+
id, timestamp, name, description, unit, metric_type,
|
|
637
|
+
value_int, value_double, histogram_data,
|
|
638
|
+
service_name, scope_name, attributes, resource_attributes
|
|
639
|
+
FROM metrics
|
|
640
|
+
${whereSql}
|
|
641
|
+
ORDER BY timestamp DESC
|
|
642
|
+
LIMIT @limit OFFSET @offset
|
|
643
|
+
`);
|
|
644
|
+
|
|
645
|
+
return stmt.all({ ...params, limit, offset });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getMetricById(id) {
|
|
649
|
+
const metric = db.prepare('SELECT * FROM metrics WHERE id = ?').get(id);
|
|
650
|
+
if (metric) {
|
|
651
|
+
metric.attributes = JSON.parse(metric.attributes || '{}');
|
|
652
|
+
metric.resource_attributes = JSON.parse(metric.resource_attributes || '{}');
|
|
653
|
+
if (metric.histogram_data) {
|
|
654
|
+
metric.histogram_data = JSON.parse(metric.histogram_data);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return metric;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function getMetricCount(filters = {}) {
|
|
661
|
+
if (Object.keys(filters).length === 0) {
|
|
662
|
+
return db.prepare('SELECT COUNT(*) as cnt FROM metrics').get().cnt;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const where = [];
|
|
666
|
+
const params = {};
|
|
667
|
+
|
|
668
|
+
if (filters.name) {
|
|
669
|
+
where.push('name = @name');
|
|
670
|
+
params.name = filters.name;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (filters.service_name) {
|
|
674
|
+
where.push('service_name = @service_name');
|
|
675
|
+
params.service_name = filters.service_name;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
679
|
+
return db.prepare(`SELECT COUNT(*) as cnt FROM metrics ${whereSql}`).get(params).cnt;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function getMetricsSummary(filters = {}) {
|
|
683
|
+
const fromTs = filters.date_from || 0;
|
|
684
|
+
const toTs = filters.date_to || Date.now();
|
|
685
|
+
|
|
686
|
+
return db.prepare(`
|
|
687
|
+
SELECT
|
|
688
|
+
name,
|
|
689
|
+
service_name,
|
|
690
|
+
metric_type,
|
|
691
|
+
COUNT(*) as data_points,
|
|
692
|
+
MIN(timestamp) as first_seen,
|
|
693
|
+
MAX(timestamp) as last_seen,
|
|
694
|
+
SUM(value_int) as sum_int,
|
|
695
|
+
AVG(value_double) as avg_double,
|
|
696
|
+
MAX(value_int) as max_int,
|
|
697
|
+
MIN(value_int) as min_int
|
|
698
|
+
FROM metrics
|
|
699
|
+
WHERE timestamp >= ? AND timestamp <= ?
|
|
700
|
+
GROUP BY name, service_name
|
|
701
|
+
ORDER BY data_points DESC
|
|
702
|
+
`).all(fromTs, toTs);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function getTokenUsage(filters = {}) {
|
|
706
|
+
return db.prepare(`
|
|
707
|
+
SELECT
|
|
708
|
+
service_name,
|
|
709
|
+
json_extract(attributes, '$.model') as model,
|
|
710
|
+
json_extract(attributes, '$.type') as token_type,
|
|
711
|
+
SUM(value_int) as total_tokens
|
|
712
|
+
FROM metrics
|
|
713
|
+
WHERE name LIKE '%token%' OR name LIKE '%usage%'
|
|
714
|
+
GROUP BY service_name, model, token_type
|
|
715
|
+
`).all();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function getDistinctMetricNames() {
|
|
719
|
+
return db.prepare('SELECT DISTINCT name FROM metrics WHERE name IS NOT NULL ORDER BY name').all()
|
|
720
|
+
.map(r => r.name);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function getDistinctMetricServices() {
|
|
724
|
+
return db.prepare('SELECT DISTINCT service_name FROM metrics WHERE service_name IS NOT NULL ORDER BY service_name').all()
|
|
725
|
+
.map(r => r.service_name);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ==================== Analytics Functions ====================
|
|
729
|
+
|
|
730
|
+
function getTokenTrends({ interval = 'hour', days = 7 } = {}) {
|
|
731
|
+
const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
732
|
+
|
|
733
|
+
let bucketSize;
|
|
734
|
+
let dateFormat;
|
|
735
|
+
|
|
736
|
+
switch (interval) {
|
|
737
|
+
case 'day':
|
|
738
|
+
bucketSize = 24 * 60 * 60 * 1000;
|
|
739
|
+
dateFormat = '%Y-%m-%d';
|
|
740
|
+
break;
|
|
741
|
+
case 'hour':
|
|
742
|
+
default:
|
|
743
|
+
bucketSize = 60 * 60 * 1000;
|
|
744
|
+
dateFormat = '%Y-%m-%d %H:00';
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return db.prepare(`
|
|
749
|
+
SELECT
|
|
750
|
+
(timestamp / @bucketSize) * @bucketSize as bucket,
|
|
751
|
+
strftime(@dateFormat, timestamp / 1000, 'unixepoch') as label,
|
|
752
|
+
SUM(prompt_tokens) as prompt_tokens,
|
|
753
|
+
SUM(completion_tokens) as completion_tokens,
|
|
754
|
+
SUM(total_tokens) as total_tokens,
|
|
755
|
+
SUM(estimated_cost) as total_cost,
|
|
756
|
+
COUNT(*) as request_count
|
|
757
|
+
FROM traces
|
|
758
|
+
WHERE timestamp >= @fromTs
|
|
759
|
+
GROUP BY bucket
|
|
760
|
+
ORDER BY bucket ASC
|
|
761
|
+
`).all({ bucketSize, dateFormat, fromTs });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function getCostByTool({ days = 30 } = {}) {
|
|
765
|
+
const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
766
|
+
|
|
767
|
+
return db.prepare(`
|
|
768
|
+
SELECT
|
|
769
|
+
provider,
|
|
770
|
+
service_name,
|
|
771
|
+
SUM(estimated_cost) as total_cost,
|
|
772
|
+
SUM(total_tokens) as total_tokens,
|
|
773
|
+
SUM(prompt_tokens) as prompt_tokens,
|
|
774
|
+
SUM(completion_tokens) as completion_tokens,
|
|
775
|
+
COUNT(*) as request_count,
|
|
776
|
+
AVG(duration_ms) as avg_duration
|
|
777
|
+
FROM traces
|
|
778
|
+
WHERE timestamp >= @fromTs
|
|
779
|
+
GROUP BY provider, service_name
|
|
780
|
+
ORDER BY total_cost DESC
|
|
781
|
+
`).all({ fromTs });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function getCostByModel({ days = 30 } = {}) {
|
|
785
|
+
const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
786
|
+
|
|
787
|
+
return db.prepare(`
|
|
788
|
+
SELECT
|
|
789
|
+
model,
|
|
790
|
+
provider,
|
|
791
|
+
SUM(estimated_cost) as total_cost,
|
|
792
|
+
SUM(total_tokens) as total_tokens,
|
|
793
|
+
SUM(prompt_tokens) as prompt_tokens,
|
|
794
|
+
SUM(completion_tokens) as completion_tokens,
|
|
795
|
+
COUNT(*) as request_count
|
|
796
|
+
FROM traces
|
|
797
|
+
WHERE timestamp >= @fromTs AND model IS NOT NULL
|
|
798
|
+
GROUP BY model
|
|
799
|
+
ORDER BY total_cost DESC
|
|
800
|
+
`).all({ fromTs });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function getDailyStats({ days = 30 } = {}) {
|
|
804
|
+
const fromTs = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
805
|
+
const bucketSize = 24 * 60 * 60 * 1000;
|
|
806
|
+
|
|
807
|
+
return db.prepare(`
|
|
808
|
+
SELECT
|
|
809
|
+
(timestamp / @bucketSize) * @bucketSize as bucket,
|
|
810
|
+
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch') as date,
|
|
811
|
+
SUM(total_tokens) as tokens,
|
|
812
|
+
SUM(estimated_cost) as cost,
|
|
813
|
+
COUNT(*) as requests
|
|
814
|
+
FROM traces
|
|
815
|
+
WHERE timestamp >= @fromTs
|
|
816
|
+
GROUP BY bucket
|
|
817
|
+
ORDER BY bucket ASC
|
|
818
|
+
`).all({ bucketSize, fromTs });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
module.exports = {
|
|
822
|
+
insertTrace,
|
|
823
|
+
getTraces,
|
|
824
|
+
getTraceById,
|
|
825
|
+
getSpansByTraceId,
|
|
826
|
+
getStats,
|
|
827
|
+
getTraceCount,
|
|
828
|
+
getDistinctModels,
|
|
829
|
+
setInsertTraceHook,
|
|
830
|
+
// Logs
|
|
831
|
+
insertLog,
|
|
832
|
+
getLogs,
|
|
833
|
+
getLogById,
|
|
834
|
+
getLogsByTraceId,
|
|
835
|
+
getLogCount,
|
|
836
|
+
getDistinctEventNames,
|
|
837
|
+
getDistinctLogServices,
|
|
838
|
+
setInsertLogHook,
|
|
839
|
+
// Metrics
|
|
840
|
+
insertMetric,
|
|
841
|
+
getMetrics,
|
|
842
|
+
getMetricById,
|
|
843
|
+
getMetricCount,
|
|
844
|
+
getMetricsSummary,
|
|
845
|
+
getTokenUsage,
|
|
846
|
+
getDistinctMetricNames,
|
|
847
|
+
getDistinctMetricServices,
|
|
848
|
+
setInsertMetricHook,
|
|
849
|
+
// Analytics
|
|
850
|
+
getTokenTrends,
|
|
851
|
+
getCostByTool,
|
|
852
|
+
getCostByModel,
|
|
853
|
+
getDailyStats,
|
|
854
|
+
// Constants
|
|
855
|
+
DB_PATH,
|
|
856
|
+
DATA_DIR
|
|
857
|
+
};
|