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/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
+ };