@llmtap/collector 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/dist/index.js ADDED
@@ -0,0 +1,1709 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ closeDb: () => closeDb,
34
+ createServer: () => createServer,
35
+ enforceRetention: () => enforceRetention,
36
+ getDb: () => getDb,
37
+ getOtlpEndpoint: () => getOtlpEndpoint,
38
+ resetDb: () => resetDb,
39
+ seedDemoData: () => seedDemoData,
40
+ startRetentionSchedule: () => startRetentionSchedule,
41
+ startServer: () => startServer
42
+ });
43
+ module.exports = __toCommonJS(index_exports);
44
+
45
+ // src/server.ts
46
+ var import_fastify = __toESM(require("fastify"));
47
+ var import_cors = __toESM(require("@fastify/cors"));
48
+ var import_static = __toESM(require("@fastify/static"));
49
+ var import_zod4 = require("zod");
50
+ var import_shared10 = require("@llmtap/shared");
51
+
52
+ // src/db.ts
53
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"));
54
+ var import_node_path = __toESM(require("path"));
55
+ var import_node_os = __toESM(require("os"));
56
+ var import_node_fs = __toESM(require("fs"));
57
+ var import_shared = require("@llmtap/shared");
58
+ var db = null;
59
+ var retentionCheckInterval = null;
60
+ var migrations = [
61
+ {
62
+ version: 1,
63
+ description: "Initial schema \u2014 spans table and indexes",
64
+ up(db2) {
65
+ db2.exec(`
66
+ CREATE TABLE IF NOT EXISTS spans (
67
+ spanId TEXT PRIMARY KEY,
68
+ traceId TEXT NOT NULL,
69
+ parentSpanId TEXT,
70
+ name TEXT NOT NULL,
71
+ operationName TEXT NOT NULL,
72
+ providerName TEXT NOT NULL,
73
+ startTime INTEGER NOT NULL,
74
+ endTime INTEGER,
75
+ duration INTEGER,
76
+ requestModel TEXT NOT NULL,
77
+ responseModel TEXT,
78
+ inputTokens INTEGER DEFAULT 0,
79
+ outputTokens INTEGER DEFAULT 0,
80
+ totalTokens INTEGER DEFAULT 0,
81
+ inputCost REAL DEFAULT 0,
82
+ outputCost REAL DEFAULT 0,
83
+ totalCost REAL DEFAULT 0,
84
+ temperature REAL,
85
+ maxTokens INTEGER,
86
+ topP REAL,
87
+ inputMessages TEXT,
88
+ outputMessages TEXT,
89
+ toolCalls TEXT,
90
+ status TEXT NOT NULL DEFAULT 'ok',
91
+ errorType TEXT,
92
+ errorMessage TEXT,
93
+ tags TEXT,
94
+ sessionId TEXT,
95
+ userId TEXT,
96
+ createdAt INTEGER DEFAULT (strftime('%s','now') * 1000)
97
+ );
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_spans_traceId ON spans(traceId);
100
+ CREATE INDEX IF NOT EXISTS idx_spans_startTime ON spans(startTime);
101
+ CREATE INDEX IF NOT EXISTS idx_spans_providerName ON spans(providerName);
102
+ CREATE INDEX IF NOT EXISTS idx_spans_requestModel ON spans(requestModel);
103
+ CREATE INDEX IF NOT EXISTS idx_spans_status ON spans(status);
104
+ `);
105
+ }
106
+ },
107
+ {
108
+ version: 2,
109
+ description: "Add sessionId index for session queries",
110
+ up(db2) {
111
+ db2.exec(
112
+ `CREATE INDEX IF NOT EXISTS idx_spans_sessionId ON spans(sessionId);`
113
+ );
114
+ }
115
+ }
116
+ ];
117
+ function ensureMigrationsTable(db2) {
118
+ db2.exec(`
119
+ CREATE TABLE IF NOT EXISTS _migrations (
120
+ version INTEGER PRIMARY KEY,
121
+ description TEXT NOT NULL,
122
+ appliedAt INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
123
+ );
124
+ `);
125
+ }
126
+ function getAppliedVersion(db2) {
127
+ const row = db2.prepare("SELECT MAX(version) as maxVer FROM _migrations").get();
128
+ return row.maxVer ?? 0;
129
+ }
130
+ function runMigrations(db2) {
131
+ ensureMigrationsTable(db2);
132
+ const currentVersion = getAppliedVersion(db2);
133
+ const insertMigration = db2.prepare(
134
+ "INSERT INTO _migrations (version, description) VALUES (@version, @description)"
135
+ );
136
+ const applyPending = db2.transaction(() => {
137
+ for (const migration of migrations) {
138
+ if (migration.version <= currentVersion) continue;
139
+ migration.up(db2);
140
+ insertMigration.run({
141
+ version: migration.version,
142
+ description: migration.description
143
+ });
144
+ }
145
+ });
146
+ applyPending();
147
+ }
148
+ function getDb() {
149
+ if (db) return db;
150
+ const dbDir = process.env.LLMTAP_DB_DIR ? import_node_path.default.resolve(process.env.LLMTAP_DB_DIR) : import_node_path.default.join(import_node_os.default.homedir(), import_shared.DB_DIR_NAME);
151
+ if (!import_node_fs.default.existsSync(dbDir)) {
152
+ import_node_fs.default.mkdirSync(dbDir, { recursive: true });
153
+ }
154
+ const dbPath = process.env.LLMTAP_DB_PATH ? import_node_path.default.resolve(process.env.LLMTAP_DB_PATH) : import_node_path.default.join(dbDir, import_shared.DB_FILE_NAME);
155
+ db = new import_better_sqlite3.default(dbPath);
156
+ db.pragma("journal_mode = WAL");
157
+ db.pragma("foreign_keys = ON");
158
+ db.pragma("busy_timeout = 5000");
159
+ runMigrations(db);
160
+ return db;
161
+ }
162
+ function closeDb() {
163
+ if (retentionCheckInterval) {
164
+ clearInterval(retentionCheckInterval);
165
+ retentionCheckInterval = null;
166
+ }
167
+ if (db) {
168
+ try {
169
+ db.pragma("wal_checkpoint(TRUNCATE)");
170
+ } catch {
171
+ }
172
+ db.close();
173
+ db = null;
174
+ }
175
+ }
176
+ function resetDb() {
177
+ const d = getDb();
178
+ d.exec("DELETE FROM spans");
179
+ d.exec("VACUUM");
180
+ }
181
+ function enforceRetention(retentionDays) {
182
+ if (retentionDays <= 0) return 0;
183
+ const d = getDb();
184
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
185
+ const result = d.prepare("DELETE FROM spans WHERE startTime < @cutoff").run({ cutoff });
186
+ if (result.changes > 0) {
187
+ d.exec("VACUUM");
188
+ }
189
+ return result.changes;
190
+ }
191
+ function startRetentionSchedule(retentionDays) {
192
+ if (retentionDays <= 0) return;
193
+ enforceRetention(retentionDays);
194
+ retentionCheckInterval = setInterval(
195
+ () => enforceRetention(retentionDays),
196
+ 60 * 60 * 1e3
197
+ );
198
+ if (retentionCheckInterval.unref) {
199
+ retentionCheckInterval.unref();
200
+ }
201
+ }
202
+
203
+ // src/seed.ts
204
+ function seedDemoData() {
205
+ const db2 = getDb();
206
+ const count = db2.prepare("SELECT COUNT(*) as c FROM spans").get();
207
+ if (count.c > 0) return;
208
+ const now = Date.now();
209
+ const hour = 36e5;
210
+ const traces = [
211
+ {
212
+ traceId: "tr_demo_chatbot_session_001",
213
+ name: "customer-support-chat",
214
+ spans: [
215
+ {
216
+ spanId: "sp_demo_001a",
217
+ name: "classify-intent",
218
+ operationName: "chat.completions.create",
219
+ providerName: "openai",
220
+ requestModel: "gpt-4o-mini",
221
+ responseModel: "gpt-4o-mini-2024-07-18",
222
+ inputTokens: 245,
223
+ outputTokens: 32,
224
+ totalTokens: 277,
225
+ inputCost: 368e-7,
226
+ outputCost: 192e-7,
227
+ totalCost: 56e-6,
228
+ status: "ok",
229
+ startOffset: -22 * hour,
230
+ duration: 420,
231
+ inputMessages: JSON.stringify([
232
+ { role: "system", content: "You are an intent classifier. Classify the user message into: billing, technical, general, urgent." },
233
+ { role: "user", content: "I can't access my account and I have a deadline in 2 hours!" }
234
+ ]),
235
+ outputMessages: JSON.stringify([
236
+ { role: "assistant", content: "urgent" }
237
+ ]),
238
+ temperature: 0
239
+ },
240
+ {
241
+ spanId: "sp_demo_001b",
242
+ parentSpanId: "sp_demo_001a",
243
+ name: "generate-response",
244
+ operationName: "chat.completions.create",
245
+ providerName: "openai",
246
+ requestModel: "gpt-4o",
247
+ responseModel: "gpt-4o-2024-08-06",
248
+ inputTokens: 580,
249
+ outputTokens: 245,
250
+ totalTokens: 825,
251
+ inputCost: 145e-5,
252
+ outputCost: 245e-5,
253
+ totalCost: 39e-4,
254
+ status: "ok",
255
+ startOffset: -22 * hour + 450,
256
+ duration: 1800,
257
+ inputMessages: JSON.stringify([
258
+ { role: "system", content: "You are a helpful customer support agent. The user has an urgent account access issue." },
259
+ { role: "user", content: "I can't access my account and I have a deadline in 2 hours!" }
260
+ ]),
261
+ outputMessages: JSON.stringify([
262
+ { role: "assistant", content: "I understand the urgency. Let me help you regain access to your account right away.\n\n1. First, try resetting your password at our recovery page\n2. If that doesn't work, I can verify your identity and unlock your account manually\n3. For your deadline, I can also temporarily extend any submission windows\n\nWhich would you like to try first?" }
263
+ ]),
264
+ temperature: 0.7
265
+ }
266
+ ]
267
+ },
268
+ {
269
+ traceId: "tr_demo_code_review_002",
270
+ name: "ai-code-review",
271
+ spans: [
272
+ {
273
+ spanId: "sp_demo_002a",
274
+ name: "analyze-diff",
275
+ operationName: "messages.create",
276
+ providerName: "anthropic",
277
+ requestModel: "claude-sonnet-4-20250514",
278
+ responseModel: "claude-sonnet-4-20250514",
279
+ inputTokens: 1850,
280
+ outputTokens: 620,
281
+ totalTokens: 2470,
282
+ inputCost: 555e-5,
283
+ outputCost: 93e-4,
284
+ totalCost: 0.01485,
285
+ status: "ok",
286
+ startOffset: -18 * hour,
287
+ duration: 3200,
288
+ inputMessages: JSON.stringify([
289
+ { role: "system", content: "You are a senior code reviewer. Analyze pull requests for bugs, security issues, and best practices." },
290
+ { role: "user", content: "Review this PR diff:\n```diff\n+async function getUserData(userId) {\n+ const query = `SELECT * FROM users WHERE id = '${userId}'`;\n+ return db.query(query);\n+}\n```" }
291
+ ]),
292
+ outputMessages: JSON.stringify([
293
+ { role: "assistant", content: "**Critical: SQL Injection Vulnerability**\n\nThe code directly interpolates `userId` into the SQL query string, creating a SQL injection attack vector.\n\n**Fix:** Use parameterized queries:\n```javascript\nasync function getUserData(userId) {\n return db.query('SELECT * FROM users WHERE id = $1', [userId]);\n}\n```\n\n**Additional recommendations:**\n- Add input validation for userId format\n- Consider selecting specific columns instead of `SELECT *`\n- Add TypeScript types for the return value" }
294
+ ]),
295
+ temperature: 0.3
296
+ }
297
+ ]
298
+ },
299
+ {
300
+ traceId: "tr_demo_rag_pipeline_003",
301
+ name: "rag-document-qa",
302
+ spans: [
303
+ {
304
+ spanId: "sp_demo_003a",
305
+ name: "embed-query",
306
+ operationName: "chat.completions.create",
307
+ providerName: "openai",
308
+ requestModel: "gpt-4o-mini",
309
+ responseModel: "gpt-4o-mini-2024-07-18",
310
+ inputTokens: 45,
311
+ outputTokens: 12,
312
+ totalTokens: 57,
313
+ inputCost: 68e-7,
314
+ outputCost: 72e-7,
315
+ totalCost: 14e-6,
316
+ status: "ok",
317
+ startOffset: -12 * hour,
318
+ duration: 180,
319
+ inputMessages: JSON.stringify([
320
+ { role: "user", content: "Rewrite as a search query: What are the company's Q3 revenue numbers?" }
321
+ ]),
322
+ outputMessages: JSON.stringify([
323
+ { role: "assistant", content: "Q3 quarterly revenue financial results earnings" }
324
+ ]),
325
+ temperature: 0
326
+ },
327
+ {
328
+ spanId: "sp_demo_003b",
329
+ parentSpanId: "sp_demo_003a",
330
+ name: "synthesize-answer",
331
+ operationName: "chat.completions.create",
332
+ providerName: "openai",
333
+ requestModel: "gpt-4o",
334
+ responseModel: "gpt-4o-2024-08-06",
335
+ inputTokens: 2100,
336
+ outputTokens: 380,
337
+ totalTokens: 2480,
338
+ inputCost: 525e-5,
339
+ outputCost: 38e-4,
340
+ totalCost: 905e-5,
341
+ status: "ok",
342
+ startOffset: -12 * hour + 500,
343
+ duration: 2800,
344
+ inputMessages: JSON.stringify([
345
+ { role: "system", content: "Answer the user's question using only the provided context. Cite sources." },
346
+ { role: "user", content: "What are the company's Q3 revenue numbers?\n\nContext:\n[Doc 1] Q3 2024 Earnings Report: Total revenue reached $4.2B, up 32% YoY...\n[Doc 2] Segment breakdown: Cloud services $2.1B, Enterprise $1.4B, Consumer $0.7B..." }
347
+ ]),
348
+ outputMessages: JSON.stringify([
349
+ { role: "assistant", content: "Based on the Q3 2024 Earnings Report, the company's Q3 revenue numbers are:\n\n- **Total Revenue:** $4.2 billion (up 32% year-over-year) [Doc 1]\n- **Cloud Services:** $2.1 billion [Doc 2]\n- **Enterprise:** $1.4 billion [Doc 2]\n- **Consumer:** $0.7 billion [Doc 2]\n\nCloud services represents the largest segment at 50% of total revenue." }
350
+ ]),
351
+ temperature: 0.2
352
+ }
353
+ ]
354
+ },
355
+ {
356
+ traceId: "tr_demo_agent_tools_004",
357
+ name: "multi-step-agent",
358
+ spans: [
359
+ {
360
+ spanId: "sp_demo_004a",
361
+ name: "agent-planning",
362
+ operationName: "chat.completions.create",
363
+ providerName: "openai",
364
+ requestModel: "gpt-4o",
365
+ responseModel: "gpt-4o-2024-08-06",
366
+ inputTokens: 890,
367
+ outputTokens: 156,
368
+ totalTokens: 1046,
369
+ inputCost: 2225e-6,
370
+ outputCost: 156e-5,
371
+ totalCost: 3785e-6,
372
+ status: "ok",
373
+ startOffset: -8 * hour,
374
+ duration: 1200,
375
+ toolCalls: JSON.stringify([
376
+ { id: "call_abc123", name: "get_weather", arguments: '{"location": "San Francisco, CA"}', result: '{"temp": 62, "condition": "foggy", "humidity": 78}' },
377
+ { id: "call_def456", name: "get_calendar", arguments: '{"date": "today"}', result: '{"events": [{"time": "2pm", "title": "Team standup"}, {"time": "4pm", "title": "Design review"}]}' }
378
+ ]),
379
+ inputMessages: JSON.stringify([
380
+ { role: "system", content: "You are a personal assistant with access to weather and calendar tools." },
381
+ { role: "user", content: "What's my afternoon looking like? Should I bring an umbrella?" }
382
+ ]),
383
+ outputMessages: JSON.stringify([
384
+ { role: "assistant", content: "Here's your afternoon:\n\n**Schedule:**\n- 2:00 PM - Team standup\n- 4:00 PM - Design review\n\n**Weather:** 62\xB0F and foggy with 78% humidity. No rain expected, but a light jacket would be smart. No umbrella needed!" }
385
+ ]),
386
+ temperature: 0.5
387
+ }
388
+ ]
389
+ },
390
+ {
391
+ traceId: "tr_demo_error_005",
392
+ name: "content-moderation",
393
+ spans: [
394
+ {
395
+ spanId: "sp_demo_005a",
396
+ name: "moderate-content",
397
+ operationName: "messages.create",
398
+ providerName: "anthropic",
399
+ requestModel: "claude-sonnet-4-20250514",
400
+ responseModel: "claude-sonnet-4-20250514",
401
+ inputTokens: 320,
402
+ outputTokens: 0,
403
+ totalTokens: 320,
404
+ inputCost: 96e-5,
405
+ outputCost: 0,
406
+ totalCost: 96e-5,
407
+ status: "error",
408
+ errorType: "RateLimitError",
409
+ errorMessage: "Rate limit exceeded: Too many requests. Please retry after 30 seconds.",
410
+ startOffset: -4 * hour,
411
+ duration: 250,
412
+ inputMessages: JSON.stringify([
413
+ { role: "user", content: "Check this user-generated content for policy violations..." }
414
+ ]),
415
+ temperature: 0
416
+ }
417
+ ]
418
+ },
419
+ {
420
+ traceId: "tr_demo_summarize_006",
421
+ name: "document-summarizer",
422
+ spans: [
423
+ {
424
+ spanId: "sp_demo_006a",
425
+ name: "chunk-summarize-1",
426
+ operationName: "messages.create",
427
+ providerName: "anthropic",
428
+ requestModel: "claude-sonnet-4-20250514",
429
+ responseModel: "claude-sonnet-4-20250514",
430
+ inputTokens: 3200,
431
+ outputTokens: 450,
432
+ totalTokens: 3650,
433
+ inputCost: 96e-4,
434
+ outputCost: 675e-5,
435
+ totalCost: 0.01635,
436
+ status: "ok",
437
+ startOffset: -2 * hour,
438
+ duration: 2400,
439
+ inputMessages: JSON.stringify([
440
+ { role: "system", content: "Summarize the following document chunk concisely." },
441
+ { role: "user", content: "Chapter 1: The integration of large language models into enterprise workflows has fundamentally changed how organizations approach knowledge management..." }
442
+ ]),
443
+ outputMessages: JSON.stringify([
444
+ { role: "assistant", content: "This chapter discusses how LLM adoption in enterprises has transformed knowledge management, shifting from static document repositories to dynamic, queryable knowledge systems. Key themes: reduced onboarding time (40% avg), improved cross-team knowledge sharing, and new challenges around data privacy and model governance." }
445
+ ]),
446
+ temperature: 0.3
447
+ },
448
+ {
449
+ spanId: "sp_demo_006b",
450
+ parentSpanId: "sp_demo_006a",
451
+ name: "chunk-summarize-2",
452
+ operationName: "messages.create",
453
+ providerName: "anthropic",
454
+ requestModel: "claude-sonnet-4-20250514",
455
+ responseModel: "claude-sonnet-4-20250514",
456
+ inputTokens: 2800,
457
+ outputTokens: 380,
458
+ totalTokens: 3180,
459
+ inputCost: 84e-4,
460
+ outputCost: 57e-4,
461
+ totalCost: 0.0141,
462
+ status: "ok",
463
+ startOffset: -2 * hour + 2500,
464
+ duration: 2100,
465
+ inputMessages: JSON.stringify([
466
+ { role: "system", content: "Summarize the following document chunk concisely." },
467
+ { role: "user", content: "Chapter 2: Implementation best practices for LLM-powered systems require careful consideration of prompt engineering, evaluation frameworks, and cost optimization strategies..." }
468
+ ]),
469
+ outputMessages: JSON.stringify([
470
+ { role: "assistant", content: "Chapter 2 covers LLM implementation best practices: structured prompt templates, A/B evaluation frameworks comparing model outputs, and cost optimization through model routing (using smaller models for simple tasks). Recommends starting with a pilot program before enterprise-wide rollout." }
471
+ ]),
472
+ temperature: 0.3
473
+ },
474
+ {
475
+ spanId: "sp_demo_006c",
476
+ parentSpanId: "sp_demo_006a",
477
+ name: "final-synthesis",
478
+ operationName: "messages.create",
479
+ providerName: "anthropic",
480
+ requestModel: "claude-sonnet-4-20250514",
481
+ responseModel: "claude-sonnet-4-20250514",
482
+ inputTokens: 950,
483
+ outputTokens: 280,
484
+ totalTokens: 1230,
485
+ inputCost: 285e-5,
486
+ outputCost: 42e-4,
487
+ totalCost: 705e-5,
488
+ status: "ok",
489
+ startOffset: -2 * hour + 5e3,
490
+ duration: 1500,
491
+ inputMessages: JSON.stringify([
492
+ { role: "system", content: "Combine these chapter summaries into a cohesive executive summary." },
493
+ { role: "user", content: "[Summary 1] LLM adoption transforms enterprise knowledge management...\n[Summary 2] Implementation requires prompt engineering, evaluation, cost optimization..." }
494
+ ]),
495
+ outputMessages: JSON.stringify([
496
+ { role: "assistant", content: "**Executive Summary**\n\nEnterprise LLM adoption is transforming knowledge management from static repositories to dynamic, queryable systems, yielding 40% faster onboarding and improved cross-team collaboration. Successful implementation requires structured prompt templates, rigorous A/B evaluation, and intelligent cost optimization through model routing. Organizations should begin with pilot programs, prioritizing data privacy and model governance frameworks before scaling enterprise-wide." }
497
+ ]),
498
+ temperature: 0.4
499
+ }
500
+ ]
501
+ }
502
+ ];
503
+ const insertStmt = db2.prepare(`
504
+ INSERT INTO spans (
505
+ spanId, traceId, parentSpanId, name, operationName, providerName,
506
+ startTime, endTime, duration, requestModel, responseModel,
507
+ inputTokens, outputTokens, totalTokens, inputCost, outputCost, totalCost,
508
+ temperature, inputMessages, outputMessages, toolCalls,
509
+ status, errorType, errorMessage
510
+ ) VALUES (
511
+ @spanId, @traceId, @parentSpanId, @name, @operationName, @providerName,
512
+ @startTime, @endTime, @duration, @requestModel, @responseModel,
513
+ @inputTokens, @outputTokens, @totalTokens, @inputCost, @outputCost, @totalCost,
514
+ @temperature, @inputMessages, @outputMessages, @toolCalls,
515
+ @status, @errorType, @errorMessage
516
+ )
517
+ `);
518
+ const insertAll = db2.transaction(() => {
519
+ for (const trace of traces) {
520
+ for (const span of trace.spans) {
521
+ const startTime = now + span.startOffset;
522
+ const endTime = startTime + span.duration;
523
+ insertStmt.run({
524
+ spanId: span.spanId,
525
+ traceId: trace.traceId,
526
+ parentSpanId: span.parentSpanId ?? null,
527
+ name: span.name,
528
+ operationName: span.operationName,
529
+ providerName: span.providerName,
530
+ startTime,
531
+ endTime,
532
+ duration: span.duration,
533
+ requestModel: span.requestModel,
534
+ responseModel: span.responseModel,
535
+ inputTokens: span.inputTokens,
536
+ outputTokens: span.outputTokens,
537
+ totalTokens: span.totalTokens,
538
+ inputCost: span.inputCost,
539
+ outputCost: span.outputCost,
540
+ totalCost: span.totalCost,
541
+ temperature: span.temperature ?? null,
542
+ inputMessages: span.inputMessages ?? null,
543
+ outputMessages: span.outputMessages ?? null,
544
+ toolCalls: span.toolCalls ?? null,
545
+ status: span.status,
546
+ errorType: span.errorType ?? null,
547
+ errorMessage: span.errorMessage ?? null
548
+ });
549
+ }
550
+ }
551
+ });
552
+ insertAll();
553
+ }
554
+
555
+ // src/schemas.ts
556
+ var import_zod = require("zod");
557
+ var MAX_ID_LEN = 256;
558
+ var MAX_SHORT_STRING = 512;
559
+ var MAX_CONTENT_STRING = 1e5;
560
+ var MAX_ERROR_MESSAGE = 1e4;
561
+ var MAX_TOOL_ARGS = 2e5;
562
+ var MessageSchema = import_zod.z.object({
563
+ role: import_zod.z.enum(["system", "user", "assistant", "tool"]),
564
+ content: import_zod.z.string().max(MAX_CONTENT_STRING).nullable(),
565
+ name: import_zod.z.string().max(MAX_SHORT_STRING).optional(),
566
+ toolCallId: import_zod.z.string().max(MAX_ID_LEN).optional()
567
+ });
568
+ var ToolCallSchema = import_zod.z.object({
569
+ id: import_zod.z.string().max(MAX_ID_LEN),
570
+ name: import_zod.z.string().max(MAX_SHORT_STRING),
571
+ arguments: import_zod.z.string().max(MAX_TOOL_ARGS),
572
+ result: import_zod.z.string().max(MAX_TOOL_ARGS).optional(),
573
+ duration: import_zod.z.number().nonnegative().optional()
574
+ });
575
+ var SpanInputSchema = import_zod.z.object({
576
+ spanId: import_zod.z.string().min(1).max(MAX_ID_LEN),
577
+ traceId: import_zod.z.string().min(1).max(MAX_ID_LEN),
578
+ parentSpanId: import_zod.z.string().min(1).max(MAX_ID_LEN).optional(),
579
+ name: import_zod.z.string().min(1).max(MAX_SHORT_STRING),
580
+ operationName: import_zod.z.string().min(1).max(MAX_SHORT_STRING),
581
+ providerName: import_zod.z.string().min(1).max(MAX_SHORT_STRING),
582
+ startTime: import_zod.z.number().nonnegative(),
583
+ endTime: import_zod.z.number().nonnegative().optional(),
584
+ duration: import_zod.z.number().nonnegative().optional(),
585
+ requestModel: import_zod.z.string().min(1).max(MAX_SHORT_STRING),
586
+ responseModel: import_zod.z.string().max(MAX_SHORT_STRING).optional(),
587
+ inputTokens: import_zod.z.number().int().nonnegative().optional(),
588
+ outputTokens: import_zod.z.number().int().nonnegative().optional(),
589
+ totalTokens: import_zod.z.number().int().nonnegative().optional(),
590
+ inputCost: import_zod.z.number().nonnegative().optional(),
591
+ outputCost: import_zod.z.number().nonnegative().optional(),
592
+ totalCost: import_zod.z.number().nonnegative().optional(),
593
+ temperature: import_zod.z.number().min(0).max(10).optional(),
594
+ maxTokens: import_zod.z.number().int().nonnegative().optional(),
595
+ topP: import_zod.z.number().min(0).max(1).optional(),
596
+ inputMessages: import_zod.z.array(MessageSchema).max(500).optional(),
597
+ outputMessages: import_zod.z.array(MessageSchema).max(500).optional(),
598
+ toolCalls: import_zod.z.array(ToolCallSchema).max(200).optional(),
599
+ status: import_zod.z.enum(["ok", "error"]),
600
+ errorType: import_zod.z.string().max(MAX_SHORT_STRING).optional(),
601
+ errorMessage: import_zod.z.string().max(MAX_ERROR_MESSAGE).optional(),
602
+ tags: import_zod.z.record(import_zod.z.string().max(MAX_SHORT_STRING)).optional(),
603
+ sessionId: import_zod.z.string().max(MAX_ID_LEN).optional(),
604
+ userId: import_zod.z.string().max(MAX_ID_LEN).optional()
605
+ });
606
+ var IngestRequestSchema = import_zod.z.object({
607
+ spans: import_zod.z.array(SpanInputSchema).min(1).max(500)
608
+ });
609
+ var TracesQuerySchema = import_zod.z.object({
610
+ limit: import_zod.z.coerce.number().int().min(1).max(200).default(50),
611
+ offset: import_zod.z.coerce.number().int().min(0).default(0),
612
+ status: import_zod.z.enum(["ok", "error"]).optional(),
613
+ provider: import_zod.z.string().max(MAX_SHORT_STRING).optional(),
614
+ q: import_zod.z.string().max(MAX_SHORT_STRING).optional(),
615
+ periodHours: import_zod.z.coerce.number().int().min(1).max(8760).optional()
616
+ // max 1 year
617
+ });
618
+ var StatsQuerySchema = import_zod.z.object({
619
+ period: import_zod.z.coerce.number().int().min(1).max(8760).default(24)
620
+ });
621
+ var SessionsQuerySchema = import_zod.z.object({
622
+ periodHours: import_zod.z.coerce.number().int().min(1).max(8760).default(168),
623
+ limit: import_zod.z.coerce.number().int().min(1).max(200).default(50),
624
+ offset: import_zod.z.coerce.number().int().min(0).default(0)
625
+ });
626
+
627
+ // src/events.ts
628
+ var import_node_events = require("events");
629
+ var eventBus = new import_node_events.EventEmitter();
630
+ eventBus.setMaxListeners(100);
631
+ function emitSpanEvent(data) {
632
+ eventBus.emit("span", { type: "span", data });
633
+ }
634
+ function onSpanEvent(handler) {
635
+ eventBus.on("span", handler);
636
+ return () => eventBus.off("span", handler);
637
+ }
638
+
639
+ // src/otlp-forwarder.ts
640
+ var import_shared2 = require("@llmtap/shared");
641
+ var endpoint = null;
642
+ var headers = {};
643
+ var serviceName = "llmtap";
644
+ var buffer = [];
645
+ var flushTimer = null;
646
+ var FLUSH_INTERVAL_MS = 2e3;
647
+ var MAX_BATCH = 100;
648
+ function initOtlpForwarder() {
649
+ const rawEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
650
+ if (!rawEndpoint) return false;
651
+ endpoint = rawEndpoint.replace(/\/+$/, "");
652
+ if (!endpoint.endsWith("/v1/traces")) {
653
+ endpoint += "/v1/traces";
654
+ }
655
+ const rawHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS;
656
+ if (rawHeaders) {
657
+ for (const pair of rawHeaders.split(",")) {
658
+ const eq = pair.indexOf("=");
659
+ if (eq > 0) {
660
+ headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
661
+ }
662
+ }
663
+ }
664
+ serviceName = process.env.OTEL_SERVICE_NAME ?? "llmtap";
665
+ return true;
666
+ }
667
+ function forwardSpans(spans) {
668
+ if (!endpoint) return;
669
+ buffer.push(...spans);
670
+ if (buffer.length >= MAX_BATCH) {
671
+ flushOtlpBuffer();
672
+ return;
673
+ }
674
+ if (!flushTimer) {
675
+ flushTimer = setTimeout(flushOtlpBuffer, FLUSH_INTERVAL_MS);
676
+ }
677
+ }
678
+ function flushOtlpBuffer() {
679
+ if (flushTimer) {
680
+ clearTimeout(flushTimer);
681
+ flushTimer = null;
682
+ }
683
+ if (!endpoint || buffer.length === 0) return;
684
+ const batch = buffer.splice(0, MAX_BATCH);
685
+ const otlp = (0, import_shared2.spansToOtlp)(batch, serviceName);
686
+ fetch(endpoint, {
687
+ method: "POST",
688
+ headers: {
689
+ "Content-Type": "application/json",
690
+ ...headers
691
+ },
692
+ body: JSON.stringify(otlp),
693
+ signal: AbortSignal.timeout(1e4)
694
+ }).catch(() => {
695
+ });
696
+ if (buffer.length > 0) {
697
+ flushTimer = setTimeout(flushOtlpBuffer, FLUSH_INTERVAL_MS);
698
+ }
699
+ }
700
+ function getOtlpEndpoint() {
701
+ return endpoint;
702
+ }
703
+
704
+ // src/routes/ingest.ts
705
+ var import_shared3 = require("@llmtap/shared");
706
+ async function registerIngestRoute(app) {
707
+ app.post(import_shared3.ROUTES.INGEST_SPANS, async (request, reply) => {
708
+ const parsed = IngestRequestSchema.safeParse(request.body);
709
+ if (!parsed.success) {
710
+ return reply.status(400).send({
711
+ error: "Validation failed",
712
+ details: parsed.error.issues
713
+ });
714
+ }
715
+ const db2 = getDb();
716
+ const insert = db2.prepare(`
717
+ INSERT OR REPLACE INTO spans (
718
+ spanId, traceId, parentSpanId, name, operationName, providerName,
719
+ startTime, endTime, duration, requestModel, responseModel,
720
+ inputTokens, outputTokens, totalTokens,
721
+ inputCost, outputCost, totalCost,
722
+ temperature, maxTokens, topP,
723
+ inputMessages, outputMessages, toolCalls,
724
+ status, errorType, errorMessage,
725
+ tags, sessionId, userId
726
+ ) VALUES (
727
+ @spanId, @traceId, @parentSpanId, @name, @operationName, @providerName,
728
+ @startTime, @endTime, @duration, @requestModel, @responseModel,
729
+ @inputTokens, @outputTokens, @totalTokens,
730
+ @inputCost, @outputCost, @totalCost,
731
+ @temperature, @maxTokens, @topP,
732
+ @inputMessages, @outputMessages, @toolCalls,
733
+ @status, @errorType, @errorMessage,
734
+ @tags, @sessionId, @userId
735
+ )
736
+ `);
737
+ const insertMany = db2.transaction((spans) => {
738
+ for (const span of spans) {
739
+ insert.run({
740
+ spanId: span.spanId,
741
+ traceId: span.traceId,
742
+ parentSpanId: span.parentSpanId ?? null,
743
+ name: span.name,
744
+ operationName: span.operationName,
745
+ providerName: span.providerName,
746
+ startTime: span.startTime,
747
+ endTime: span.endTime ?? null,
748
+ duration: span.duration ?? null,
749
+ requestModel: span.requestModel,
750
+ responseModel: span.responseModel ?? null,
751
+ inputTokens: span.inputTokens ?? 0,
752
+ outputTokens: span.outputTokens ?? 0,
753
+ totalTokens: span.totalTokens ?? 0,
754
+ inputCost: span.inputCost ?? 0,
755
+ outputCost: span.outputCost ?? 0,
756
+ totalCost: span.totalCost ?? 0,
757
+ temperature: span.temperature ?? null,
758
+ maxTokens: span.maxTokens ?? null,
759
+ topP: span.topP ?? null,
760
+ inputMessages: span.inputMessages ? JSON.stringify(span.inputMessages) : null,
761
+ outputMessages: span.outputMessages ? JSON.stringify(span.outputMessages) : null,
762
+ toolCalls: span.toolCalls ? JSON.stringify(span.toolCalls) : null,
763
+ status: span.status,
764
+ errorType: span.errorType ?? null,
765
+ errorMessage: span.errorMessage ?? null,
766
+ tags: span.tags ? JSON.stringify(span.tags) : null,
767
+ sessionId: span.sessionId ?? null,
768
+ userId: span.userId ?? null
769
+ });
770
+ }
771
+ });
772
+ insertMany(parsed.data.spans);
773
+ for (const span of parsed.data.spans) {
774
+ emitSpanEvent(span);
775
+ }
776
+ forwardSpans(parsed.data.spans);
777
+ return reply.status(200).send({
778
+ accepted: parsed.data.spans.length
779
+ });
780
+ });
781
+ }
782
+
783
+ // src/routes/traces.ts
784
+ var import_shared4 = require("@llmtap/shared");
785
+ function safeJsonParse(val) {
786
+ if (!val) return void 0;
787
+ try {
788
+ return JSON.parse(val);
789
+ } catch {
790
+ return void 0;
791
+ }
792
+ }
793
+ function parseSpanRow(row) {
794
+ return {
795
+ ...row,
796
+ inputMessages: safeJsonParse(row.inputMessages),
797
+ outputMessages: safeJsonParse(row.outputMessages),
798
+ toolCalls: safeJsonParse(row.toolCalls),
799
+ tags: safeJsonParse(row.tags),
800
+ parentSpanId: row.parentSpanId ?? void 0,
801
+ responseModel: row.responseModel ?? void 0,
802
+ endTime: row.endTime ?? void 0,
803
+ duration: row.duration ?? void 0,
804
+ temperature: row.temperature ?? void 0,
805
+ maxTokens: row.maxTokens ?? void 0,
806
+ topP: row.topP ?? void 0,
807
+ errorType: row.errorType ?? void 0,
808
+ errorMessage: row.errorMessage ?? void 0,
809
+ sessionId: row.sessionId ?? void 0,
810
+ userId: row.userId ?? void 0
811
+ };
812
+ }
813
+ async function registerTraceRoutes(app) {
814
+ app.get(import_shared4.ROUTES.LIST_TRACES, async (request, reply) => {
815
+ const parsed = TracesQuerySchema.safeParse(request.query);
816
+ if (!parsed.success) {
817
+ return reply.status(400).send({ error: "Invalid query parameters", details: parsed.error.flatten() });
818
+ }
819
+ const { limit, offset, status, provider, q, periodHours } = parsed.data;
820
+ const db2 = getDb();
821
+ const whereConditions = [];
822
+ const havingConditions = [];
823
+ const params = { limit, offset };
824
+ if (status) {
825
+ havingConditions.push("status = @status");
826
+ params.status = status;
827
+ }
828
+ if (provider) {
829
+ whereConditions.push("providerName = @provider");
830
+ params.provider = provider;
831
+ }
832
+ if (q) {
833
+ const escaped = q.replace(/[%_]/g, "\\$&");
834
+ whereConditions.push(`
835
+ (
836
+ name LIKE @search ESCAPE '\\' OR
837
+ providerName LIKE @search ESCAPE '\\' OR
838
+ requestModel LIKE @search ESCAPE '\\' OR
839
+ COALESCE(responseModel, '') LIKE @search ESCAPE '\\' OR
840
+ COALESCE(errorMessage, '') LIKE @search ESCAPE '\\' OR
841
+ COALESCE(inputMessages, '') LIKE @search ESCAPE '\\' OR
842
+ COALESCE(outputMessages, '') LIKE @search ESCAPE '\\'
843
+ )
844
+ `);
845
+ params.search = `%${escaped}%`;
846
+ }
847
+ if (periodHours) {
848
+ whereConditions.push("startTime >= @since");
849
+ params.since = Date.now() - periodHours * 60 * 60 * 1e3;
850
+ }
851
+ const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
852
+ const havingClause = havingConditions.length > 0 ? `HAVING ${havingConditions.join(" AND ")}` : "";
853
+ const rows = db2.prepare(
854
+ `
855
+ SELECT
856
+ traceId,
857
+ MIN(name) as name,
858
+ MIN(startTime) as startTime,
859
+ MAX(endTime) as endTime,
860
+ CASE WHEN SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) > 0
861
+ THEN 'error' ELSE 'ok' END as status,
862
+ COUNT(*) as spanCount,
863
+ SUM(totalTokens) as totalTokens,
864
+ SUM(totalCost) as totalCost,
865
+ MAX(endTime) - MIN(startTime) as totalDuration
866
+ FROM spans
867
+ ${whereClause}
868
+ GROUP BY traceId
869
+ ${havingClause}
870
+ ORDER BY startTime DESC
871
+ LIMIT @limit OFFSET @offset
872
+ `
873
+ ).all(params);
874
+ const totalRow = db2.prepare(
875
+ `
876
+ SELECT COUNT(*) as total
877
+ FROM (
878
+ SELECT traceId
879
+ FROM spans
880
+ ${whereClause}
881
+ GROUP BY traceId
882
+ ${havingClause}
883
+ ) grouped_traces
884
+ `
885
+ ).get(params);
886
+ return reply.send({
887
+ traces: rows.map((r) => ({
888
+ ...r,
889
+ endTime: r.endTime ?? void 0,
890
+ totalDuration: r.totalDuration ?? void 0
891
+ })),
892
+ total: totalRow.total
893
+ });
894
+ });
895
+ app.get("/v1/traces/:traceId/spans", async (request, reply) => {
896
+ const { traceId } = request.params;
897
+ const db2 = getDb();
898
+ const rows = db2.prepare(
899
+ `SELECT * FROM spans WHERE traceId = @traceId ORDER BY startTime ASC`
900
+ ).all({ traceId });
901
+ return reply.send({
902
+ spans: rows.map(parseSpanRow)
903
+ });
904
+ });
905
+ }
906
+
907
+ // src/routes/stats.ts
908
+ var import_shared5 = require("@llmtap/shared");
909
+ async function registerStatsRoute(app) {
910
+ app.get(import_shared5.ROUTES.GET_STATS, async (request, reply) => {
911
+ const parsed = StatsQuerySchema.safeParse(request.query);
912
+ if (!parsed.success) {
913
+ return reply.status(400).send({ error: "Invalid query parameters", details: parsed.error.flatten() });
914
+ }
915
+ const periodHours = parsed.data.period;
916
+ const since = Date.now() - periodHours * 60 * 60 * 1e3;
917
+ const db2 = getDb();
918
+ const stats = db2.prepare(
919
+ `
920
+ SELECT
921
+ COUNT(DISTINCT traceId) as totalTraces,
922
+ COUNT(*) as totalSpans,
923
+ COALESCE(SUM(totalTokens), 0) as totalTokens,
924
+ COALESCE(SUM(totalCost), 0) as totalCost,
925
+ COALESCE(AVG(duration), 0) as avgDuration,
926
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount
927
+ FROM spans
928
+ WHERE startTime >= @since
929
+ `
930
+ ).get({ since });
931
+ const byProvider = db2.prepare(
932
+ `
933
+ SELECT
934
+ providerName as provider,
935
+ COUNT(*) as spanCount,
936
+ COALESCE(SUM(totalTokens), 0) as totalTokens,
937
+ COALESCE(SUM(totalCost), 0) as totalCost,
938
+ COALESCE(AVG(duration), 0) as avgDuration
939
+ FROM spans
940
+ WHERE startTime >= @since
941
+ GROUP BY providerName
942
+ ORDER BY totalCost DESC
943
+ `
944
+ ).all({ since });
945
+ const byModel = db2.prepare(
946
+ `
947
+ SELECT
948
+ requestModel as model,
949
+ providerName as provider,
950
+ COUNT(*) as spanCount,
951
+ COALESCE(SUM(totalTokens), 0) as totalTokens,
952
+ COALESCE(SUM(totalCost), 0) as totalCost,
953
+ COALESCE(AVG(duration), 0) as avgDuration
954
+ FROM spans
955
+ WHERE startTime >= @since
956
+ GROUP BY requestModel, providerName
957
+ ORDER BY totalCost DESC
958
+ `
959
+ ).all({ since });
960
+ const costOverTime = db2.prepare(
961
+ `
962
+ SELECT
963
+ (startTime / 3600000) * 3600000 as bucket,
964
+ COALESCE(SUM(totalCost), 0) as cost,
965
+ COALESCE(SUM(totalTokens), 0) as tokens,
966
+ COUNT(*) as spans
967
+ FROM spans
968
+ WHERE startTime >= @since
969
+ GROUP BY bucket
970
+ ORDER BY bucket ASC
971
+ `
972
+ ).all({ since });
973
+ return reply.send({
974
+ period: `${periodHours}h`,
975
+ ...stats,
976
+ errorRate: stats.totalSpans > 0 ? stats.errorCount / stats.totalSpans : 0,
977
+ byProvider,
978
+ byModel,
979
+ costOverTime: costOverTime.map((r) => ({
980
+ timestamp: r.bucket,
981
+ cost: r.cost,
982
+ tokens: r.tokens,
983
+ spans: r.spans
984
+ }))
985
+ });
986
+ });
987
+ }
988
+
989
+ // src/routes/sse.ts
990
+ var import_shared6 = require("@llmtap/shared");
991
+ var MAX_SSE_CONNECTIONS = 50;
992
+ var sseConnectionCount = 0;
993
+ async function registerSSERoute(app) {
994
+ app.get(import_shared6.ROUTES.SSE_STREAM, async (request, reply) => {
995
+ if (sseConnectionCount >= MAX_SSE_CONNECTIONS) {
996
+ return reply.status(503).send({ error: "Too many SSE connections" });
997
+ }
998
+ sseConnectionCount++;
999
+ reply.raw.writeHead(200, {
1000
+ "Content-Type": "text/event-stream",
1001
+ "Cache-Control": "no-cache",
1002
+ Connection: "keep-alive"
1003
+ });
1004
+ reply.raw.write("event: connected\ndata: {}\n\n");
1005
+ const heartbeat = setInterval(() => {
1006
+ reply.raw.write(":heartbeat\n\n");
1007
+ }, 15e3);
1008
+ const unsubscribe = onSpanEvent((event) => {
1009
+ reply.raw.write(
1010
+ `event: ${event.type}
1011
+ data: ${JSON.stringify(event.data)}
1012
+
1013
+ `
1014
+ );
1015
+ });
1016
+ request.raw.on("close", () => {
1017
+ sseConnectionCount--;
1018
+ clearInterval(heartbeat);
1019
+ unsubscribe();
1020
+ });
1021
+ });
1022
+ }
1023
+
1024
+ // src/routes/sessions.ts
1025
+ var import_shared7 = require("@llmtap/shared");
1026
+ async function registerSessionsRoute(app) {
1027
+ app.get(import_shared7.ROUTES.GET_SESSIONS, async (request, reply) => {
1028
+ const parsed = SessionsQuerySchema.safeParse(request.query);
1029
+ if (!parsed.success) {
1030
+ return reply.status(400).send({ error: "Invalid query parameters", details: parsed.error.flatten() });
1031
+ }
1032
+ const { periodHours, limit, offset } = parsed.data;
1033
+ const since = Date.now() - periodHours * 60 * 60 * 1e3;
1034
+ const db2 = getDb();
1035
+ const rows = db2.prepare(
1036
+ `
1037
+ SELECT
1038
+ sessionId,
1039
+ COUNT(DISTINCT traceId) as traceCount,
1040
+ COUNT(*) as spanCount,
1041
+ COALESCE(SUM(totalTokens), 0) as totalTokens,
1042
+ COALESCE(SUM(totalCost), 0) as totalCost,
1043
+ MIN(startTime) as firstSeen,
1044
+ MAX(startTime) as lastSeen,
1045
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount
1046
+ FROM spans
1047
+ WHERE sessionId IS NOT NULL AND sessionId != '' AND startTime >= @since
1048
+ GROUP BY sessionId
1049
+ ORDER BY lastSeen DESC
1050
+ LIMIT @limit OFFSET @offset
1051
+ `
1052
+ ).all({ since, limit, offset });
1053
+ const totalRow = db2.prepare(
1054
+ `
1055
+ SELECT COUNT(DISTINCT sessionId) as total
1056
+ FROM spans
1057
+ WHERE sessionId IS NOT NULL AND sessionId != '' AND startTime >= @since
1058
+ `
1059
+ ).get({ since });
1060
+ return reply.send({ sessions: rows, total: totalRow.total });
1061
+ });
1062
+ }
1063
+
1064
+ // src/routes/db-info.ts
1065
+ var import_node_path2 = __toESM(require("path"));
1066
+ var import_node_fs2 = __toESM(require("fs"));
1067
+ var import_node_os2 = __toESM(require("os"));
1068
+ var import_shared8 = require("@llmtap/shared");
1069
+ async function registerDbInfoRoute(app) {
1070
+ app.get(import_shared8.ROUTES.GET_DB_INFO, async (_request, reply) => {
1071
+ const db2 = getDb();
1072
+ const dbDir = process.env.LLMTAP_DB_DIR ? import_node_path2.default.resolve(process.env.LLMTAP_DB_DIR) : import_node_path2.default.join(import_node_os2.default.homedir(), import_shared8.DB_DIR_NAME);
1073
+ const dbPath = process.env.LLMTAP_DB_PATH ? import_node_path2.default.resolve(process.env.LLMTAP_DB_PATH) : import_node_path2.default.join(dbDir, import_shared8.DB_FILE_NAME);
1074
+ let sizeBytes = 0;
1075
+ try {
1076
+ const stat = import_node_fs2.default.statSync(dbPath);
1077
+ sizeBytes = stat.size;
1078
+ } catch {
1079
+ }
1080
+ const spanCount = db2.prepare("SELECT COUNT(*) as count FROM spans").get().count;
1081
+ const traceCount = db2.prepare("SELECT COUNT(DISTINCT traceId) as count FROM spans").get().count;
1082
+ const oldestSpan = db2.prepare("SELECT MIN(startTime) as oldest FROM spans").get().oldest;
1083
+ const newestSpan = db2.prepare("SELECT MAX(startTime) as newest FROM spans").get().newest;
1084
+ return reply.send({
1085
+ path: dbPath,
1086
+ sizeBytes,
1087
+ spanCount,
1088
+ traceCount,
1089
+ oldestSpan,
1090
+ newestSpan,
1091
+ walMode: db2.pragma("journal_mode")[0]?.journal_mode
1092
+ });
1093
+ });
1094
+ }
1095
+
1096
+ // src/routes/insights.ts
1097
+ var insightsCache = null;
1098
+ var INSIGHTS_CACHE_TTL_MS = 3e4;
1099
+ async function registerInsightsRoute(app) {
1100
+ app.get("/v1/insights", async (_request, reply) => {
1101
+ const now = Date.now();
1102
+ if (insightsCache && now - insightsCache.timestamp < INSIGHTS_CACHE_TTL_MS) {
1103
+ return reply.send(insightsCache.data);
1104
+ }
1105
+ const db2 = getDb();
1106
+ const insights = [];
1107
+ const since24h = Date.now() - 24 * 60 * 60 * 1e3;
1108
+ const since7d = Date.now() - 7 * 24 * 60 * 60 * 1e3;
1109
+ try {
1110
+ const anomalies = db2.prepare(
1111
+ `
1112
+ WITH avg_cost AS (
1113
+ SELECT AVG(trace_cost) as avgCost
1114
+ FROM (
1115
+ SELECT traceId, SUM(totalCost) as trace_cost
1116
+ FROM spans
1117
+ WHERE startTime >= @since
1118
+ GROUP BY traceId
1119
+ )
1120
+ )
1121
+ SELECT
1122
+ s.traceId,
1123
+ MIN(s.name) as name,
1124
+ SUM(s.totalCost) as totalCost,
1125
+ (SELECT avgCost FROM avg_cost) as avgCost
1126
+ FROM spans s
1127
+ WHERE s.startTime >= @since
1128
+ GROUP BY s.traceId
1129
+ HAVING totalCost > (SELECT avgCost FROM avg_cost) * 5 AND totalCost > 0.01
1130
+ ORDER BY totalCost DESC
1131
+ LIMIT 3
1132
+ `
1133
+ ).all({ since: since24h });
1134
+ for (const a of anomalies) {
1135
+ const multiplier = a.avgCost > 0 ? Math.round(a.totalCost / a.avgCost) : 0;
1136
+ insights.push({
1137
+ id: `cost_anomaly_${a.traceId}`,
1138
+ type: "cost_anomaly",
1139
+ severity: multiplier > 20 ? "critical" : "warning",
1140
+ title: `High cost trace detected`,
1141
+ description: `"${a.name}" cost $${a.totalCost.toFixed(4)} \u2014 ${multiplier}x your average trace cost of $${a.avgCost.toFixed(4)}.`,
1142
+ metric: `${multiplier}x avg`
1143
+ });
1144
+ }
1145
+ } catch {
1146
+ }
1147
+ try {
1148
+ const errors = db2.prepare(
1149
+ `
1150
+ SELECT
1151
+ COALESCE(errorType, 'Unknown') as errorType,
1152
+ COUNT(*) as errorCount,
1153
+ MAX(startTime) as latestTime
1154
+ FROM spans
1155
+ WHERE status = 'error' AND startTime >= @since
1156
+ GROUP BY errorType
1157
+ HAVING errorCount >= 3
1158
+ ORDER BY errorCount DESC
1159
+ LIMIT 3
1160
+ `
1161
+ ).all({ since: since7d });
1162
+ for (const e of errors) {
1163
+ insights.push({
1164
+ id: `error_pattern_${e.errorType}`,
1165
+ type: "error_pattern",
1166
+ severity: e.errorCount > 20 ? "critical" : "warning",
1167
+ title: `Recurring errors: ${e.errorType}`,
1168
+ description: `${e.errorCount} "${e.errorType}" errors in the last 7 days.`,
1169
+ metric: `${e.errorCount} errors`
1170
+ });
1171
+ }
1172
+ } catch {
1173
+ }
1174
+ try {
1175
+ const usage = db2.prepare(
1176
+ `
1177
+ SELECT
1178
+ requestModel as model,
1179
+ providerName as provider,
1180
+ COUNT(*) as spanCount,
1181
+ AVG(totalTokens) as avgTokens,
1182
+ SUM(totalCost) as totalCost,
1183
+ AVG(totalCost) as avgCost
1184
+ FROM spans
1185
+ WHERE startTime >= @since AND status = 'ok'
1186
+ GROUP BY requestModel, providerName
1187
+ ORDER BY totalCost DESC
1188
+ `
1189
+ ).all({ since: since7d });
1190
+ for (const u of usage) {
1191
+ const isExpensive = u.model.includes("gpt-4o") && !u.model.includes("mini") || u.model.includes("gpt-4-") || u.model.includes("claude-3-opus") || u.model.includes("claude-opus") || u.model.includes("claude-4") && !u.model.includes("haiku");
1192
+ const isLowToken = u.avgTokens < 500;
1193
+ if (isExpensive && isLowToken && u.spanCount >= 5 && u.totalCost > 0.05) {
1194
+ insights.push({
1195
+ id: `model_rec_${u.model}`,
1196
+ type: "model_recommendation",
1197
+ severity: "info",
1198
+ title: `Consider a lighter model for "${u.model}"`,
1199
+ description: `${u.spanCount} calls averaging ${Math.round(u.avgTokens)} tokens \u2014 a smaller model could save ~$${(u.totalCost * 0.7).toFixed(2)}.`,
1200
+ metric: `$${u.totalCost.toFixed(2)} spent`
1201
+ });
1202
+ }
1203
+ }
1204
+ } catch {
1205
+ }
1206
+ try {
1207
+ const wasteRows = db2.prepare(
1208
+ `
1209
+ SELECT
1210
+ name,
1211
+ requestModel as model,
1212
+ AVG(inputTokens) as avgInputTokens,
1213
+ AVG(outputTokens) as avgOutputTokens,
1214
+ COUNT(*) as spanCount,
1215
+ CASE WHEN AVG(outputTokens) > 0
1216
+ THEN CAST(AVG(inputTokens) AS REAL) / AVG(outputTokens)
1217
+ ELSE 0
1218
+ END as inputRatio
1219
+ FROM spans
1220
+ WHERE startTime >= @since AND status = 'ok' AND inputTokens > 0
1221
+ GROUP BY name, requestModel
1222
+ HAVING inputRatio > 10 AND avgInputTokens > 1000 AND spanCount >= 3
1223
+ ORDER BY inputRatio DESC
1224
+ LIMIT 3
1225
+ `
1226
+ ).all({ since: since7d });
1227
+ for (const w of wasteRows) {
1228
+ insights.push({
1229
+ id: `token_waste_${w.name}_${w.model}`,
1230
+ type: "token_waste",
1231
+ severity: "info",
1232
+ title: `High input-to-output token ratio`,
1233
+ description: `"${w.name}" uses ~${Math.round(w.avgInputTokens)} input tokens to generate ~${Math.round(w.avgOutputTokens)} output tokens (${Math.round(w.inputRatio)}:1 ratio). Consider compressing the system prompt.`,
1234
+ metric: `${Math.round(w.inputRatio)}:1 ratio`
1235
+ });
1236
+ }
1237
+ } catch {
1238
+ }
1239
+ const result = { insights };
1240
+ insightsCache = { data: result, timestamp: Date.now() };
1241
+ return reply.send(result);
1242
+ });
1243
+ }
1244
+
1245
+ // src/routes/replay.ts
1246
+ var import_zod2 = require("zod");
1247
+ var ReplaySchema = import_zod2.z.object({
1248
+ spanId: import_zod2.z.string().min(1).max(256),
1249
+ apiKey: import_zod2.z.string().min(1).max(512)
1250
+ });
1251
+ async function registerReplayRoute(app) {
1252
+ app.post("/v1/replay", async (request, reply) => {
1253
+ const parsed = ReplaySchema.safeParse(request.body);
1254
+ if (!parsed.success) {
1255
+ return reply.status(400).send({
1256
+ error: "Validation failed",
1257
+ details: parsed.error.issues
1258
+ });
1259
+ }
1260
+ const { spanId, apiKey } = parsed.data;
1261
+ const db2 = getDb();
1262
+ const span = db2.prepare(
1263
+ `SELECT providerName, requestModel, inputMessages, temperature, maxTokens, topP
1264
+ FROM spans WHERE spanId = ?`
1265
+ ).get(spanId);
1266
+ if (!span) {
1267
+ return reply.status(404).send({ error: "Span not found" });
1268
+ }
1269
+ if (!span.inputMessages) {
1270
+ return reply.status(400).send({
1271
+ error: "Span has no input messages to replay"
1272
+ });
1273
+ }
1274
+ let messages;
1275
+ try {
1276
+ messages = JSON.parse(span.inputMessages);
1277
+ } catch {
1278
+ return reply.status(400).send({ error: "Failed to parse input messages" });
1279
+ }
1280
+ const startTime = Date.now();
1281
+ try {
1282
+ if (span.providerName === "anthropic") {
1283
+ const result2 = await replayAnthropic(
1284
+ apiKey,
1285
+ span.requestModel,
1286
+ messages,
1287
+ span.temperature,
1288
+ span.maxTokens
1289
+ );
1290
+ return reply.send({
1291
+ ...result2,
1292
+ duration: Date.now() - startTime,
1293
+ provider: "anthropic",
1294
+ model: span.requestModel
1295
+ });
1296
+ }
1297
+ const result = await replayOpenAI(
1298
+ apiKey,
1299
+ span.requestModel,
1300
+ messages,
1301
+ span.temperature,
1302
+ span.maxTokens,
1303
+ span.topP,
1304
+ span.providerName
1305
+ );
1306
+ return reply.send({
1307
+ ...result,
1308
+ duration: Date.now() - startTime,
1309
+ provider: span.providerName,
1310
+ model: span.requestModel
1311
+ });
1312
+ } catch (err) {
1313
+ return reply.status(502).send({
1314
+ error: "Replay failed",
1315
+ message: err instanceof Error ? err.message : String(err),
1316
+ duration: Date.now() - startTime
1317
+ });
1318
+ }
1319
+ });
1320
+ }
1321
+ async function replayOpenAI(apiKey, model, messages, temperature, maxTokens, topP, provider) {
1322
+ const baseUrls = {
1323
+ openai: "https://api.openai.com/v1",
1324
+ deepseek: "https://api.deepseek.com/v1",
1325
+ groq: "https://api.groq.com/openai/v1",
1326
+ together: "https://api.together.xyz/v1",
1327
+ fireworks: "https://api.fireworks.ai/inference/v1",
1328
+ openrouter: "https://openrouter.ai/api/v1",
1329
+ xai: "https://api.x.ai/v1"
1330
+ };
1331
+ const baseUrl = baseUrls[provider] ?? baseUrls.openai;
1332
+ const reqBody = { model, messages };
1333
+ if (temperature !== null) reqBody.temperature = temperature;
1334
+ if (maxTokens !== null) reqBody.max_tokens = maxTokens;
1335
+ if (topP !== null) reqBody.top_p = topP;
1336
+ const res = await fetch(`${baseUrl}/chat/completions`, {
1337
+ method: "POST",
1338
+ headers: {
1339
+ "Content-Type": "application/json",
1340
+ Authorization: `Bearer ${apiKey}`
1341
+ },
1342
+ body: JSON.stringify(reqBody),
1343
+ signal: AbortSignal.timeout(6e4)
1344
+ });
1345
+ if (!res.ok) {
1346
+ const text = await res.text().catch(() => "");
1347
+ throw new Error(`API returned HTTP ${res.status}: ${text.slice(0, 500)}`);
1348
+ }
1349
+ const data = await res.json();
1350
+ const choice = data.choices?.[0];
1351
+ return {
1352
+ content: choice?.message?.content ?? "",
1353
+ inputTokens: data.usage?.prompt_tokens ?? 0,
1354
+ outputTokens: data.usage?.completion_tokens ?? 0,
1355
+ totalTokens: data.usage?.total_tokens ?? 0,
1356
+ responseModel: data.model ?? model
1357
+ };
1358
+ }
1359
+ async function replayAnthropic(apiKey, model, rawMessages, temperature, maxTokens) {
1360
+ const messages = rawMessages;
1361
+ const systemMsg = messages.find((m) => m.role === "system");
1362
+ const nonSystemMessages = messages.filter((m) => m.role !== "system");
1363
+ const reqBody = {
1364
+ model,
1365
+ messages: nonSystemMessages,
1366
+ max_tokens: maxTokens ?? 4096
1367
+ };
1368
+ if (systemMsg) reqBody.system = systemMsg.content;
1369
+ if (temperature !== null) reqBody.temperature = temperature;
1370
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
1371
+ method: "POST",
1372
+ headers: {
1373
+ "Content-Type": "application/json",
1374
+ "x-api-key": apiKey,
1375
+ "anthropic-version": "2023-06-01"
1376
+ },
1377
+ body: JSON.stringify(reqBody),
1378
+ signal: AbortSignal.timeout(6e4)
1379
+ });
1380
+ if (!res.ok) {
1381
+ const text = await res.text().catch(() => "");
1382
+ throw new Error(`API returned HTTP ${res.status}: ${text.slice(0, 500)}`);
1383
+ }
1384
+ const data = await res.json();
1385
+ const textContent = data.content?.filter((c) => c.type === "text").map((c) => c.text).join("") ?? "";
1386
+ const inputTokens = data.usage?.input_tokens ?? 0;
1387
+ const outputTokens = data.usage?.output_tokens ?? 0;
1388
+ return {
1389
+ content: textContent,
1390
+ inputTokens,
1391
+ outputTokens,
1392
+ totalTokens: inputTokens + outputTokens,
1393
+ responseModel: data.model ?? model
1394
+ };
1395
+ }
1396
+
1397
+ // src/routes/otlp.ts
1398
+ var import_zod3 = require("zod");
1399
+ var import_shared9 = require("@llmtap/shared");
1400
+ function safeParse(val) {
1401
+ if (!val) return void 0;
1402
+ try {
1403
+ return JSON.parse(val);
1404
+ } catch {
1405
+ return void 0;
1406
+ }
1407
+ }
1408
+ function rowToSpan(row) {
1409
+ return {
1410
+ spanId: row.spanId,
1411
+ traceId: row.traceId,
1412
+ parentSpanId: row.parentSpanId ?? void 0,
1413
+ name: row.name,
1414
+ operationName: row.operationName,
1415
+ providerName: row.providerName,
1416
+ startTime: row.startTime,
1417
+ endTime: row.endTime ?? void 0,
1418
+ duration: row.duration ?? void 0,
1419
+ requestModel: row.requestModel,
1420
+ responseModel: row.responseModel ?? void 0,
1421
+ inputTokens: row.inputTokens,
1422
+ outputTokens: row.outputTokens,
1423
+ totalTokens: row.totalTokens,
1424
+ inputCost: row.inputCost,
1425
+ outputCost: row.outputCost,
1426
+ totalCost: row.totalCost,
1427
+ temperature: row.temperature ?? void 0,
1428
+ maxTokens: row.maxTokens ?? void 0,
1429
+ topP: row.topP ?? void 0,
1430
+ inputMessages: safeParse(row.inputMessages),
1431
+ outputMessages: safeParse(row.outputMessages),
1432
+ toolCalls: safeParse(row.toolCalls),
1433
+ status: row.status,
1434
+ errorType: row.errorType ?? void 0,
1435
+ errorMessage: row.errorMessage ?? void 0,
1436
+ tags: safeParse(row.tags),
1437
+ sessionId: row.sessionId ?? void 0,
1438
+ userId: row.userId ?? void 0
1439
+ };
1440
+ }
1441
+ var BLOCKED_HEADERS = /* @__PURE__ */ new Set([
1442
+ "host",
1443
+ "content-length",
1444
+ "transfer-encoding",
1445
+ "connection",
1446
+ "keep-alive",
1447
+ "upgrade",
1448
+ "proxy-authorization",
1449
+ "te",
1450
+ "trailer"
1451
+ ]);
1452
+ function sanitizeHeaders(userHeaders) {
1453
+ const result = { "Content-Type": "application/json" };
1454
+ if (!userHeaders) return result;
1455
+ for (const [key, value] of Object.entries(userHeaders)) {
1456
+ if (typeof key === "string" && typeof value === "string" && !BLOCKED_HEADERS.has(key.toLowerCase())) {
1457
+ result[key] = value;
1458
+ }
1459
+ }
1460
+ return result;
1461
+ }
1462
+ var OtlpExportQuerySchema = import_zod3.z.object({
1463
+ limit: import_zod3.z.coerce.number().int().min(1).max(5e3).default(1e3),
1464
+ periodHours: import_zod3.z.coerce.number().int().min(0).max(8760).default(0),
1465
+ traceId: import_zod3.z.string().max(256).optional(),
1466
+ service: import_zod3.z.string().max(256).default("llmtap")
1467
+ });
1468
+ var OtlpForwardSchema = import_zod3.z.object({
1469
+ endpoint: import_zod3.z.string().min(1).max(2048).url().refine(
1470
+ (url) => {
1471
+ try {
1472
+ const parsed = new URL(url);
1473
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
1474
+ } catch {
1475
+ return false;
1476
+ }
1477
+ },
1478
+ { message: "endpoint must use http or https protocol" }
1479
+ ),
1480
+ headers: import_zod3.z.record(import_zod3.z.string().max(4096)).optional(),
1481
+ limit: import_zod3.z.number().int().min(1).max(5e3).optional(),
1482
+ periodHours: import_zod3.z.number().int().min(0).max(8760).optional(),
1483
+ service: import_zod3.z.string().max(256).optional()
1484
+ });
1485
+ async function registerOtlpExportRoute(app) {
1486
+ app.get("/v1/export/otlp", async (request, reply) => {
1487
+ const parsed = OtlpExportQuerySchema.safeParse(request.query);
1488
+ if (!parsed.success) {
1489
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.issues });
1490
+ }
1491
+ const { limit, periodHours, traceId, service: serviceName2 } = parsed.data;
1492
+ const db2 = getDb();
1493
+ const conditions = [];
1494
+ const params = [];
1495
+ if (periodHours > 0) {
1496
+ conditions.push("startTime >= ?");
1497
+ params.push(Date.now() - periodHours * 36e5);
1498
+ }
1499
+ if (traceId) {
1500
+ conditions.push("traceId = ?");
1501
+ params.push(traceId);
1502
+ }
1503
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1504
+ params.push(limit);
1505
+ const rows = db2.prepare(`SELECT * FROM spans ${where} ORDER BY startTime DESC LIMIT ?`).all(...params);
1506
+ const spans = rows.map(rowToSpan);
1507
+ const otlp = (0, import_shared9.spansToOtlp)(spans, serviceName2);
1508
+ return reply.header("Content-Type", "application/json").send(otlp);
1509
+ });
1510
+ app.post("/v1/export/otlp/forward", async (request, reply) => {
1511
+ const parsed = OtlpForwardSchema.safeParse(request.body);
1512
+ if (!parsed.success) {
1513
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.issues });
1514
+ }
1515
+ const body = parsed.data;
1516
+ const limit = body.limit ?? 1e3;
1517
+ const periodHours = body.periodHours ?? 0;
1518
+ const serviceName2 = body.service ?? "llmtap";
1519
+ const db2 = getDb();
1520
+ const conditions = [];
1521
+ const params = [];
1522
+ if (periodHours > 0) {
1523
+ conditions.push("startTime >= ?");
1524
+ params.push(Date.now() - periodHours * 36e5);
1525
+ }
1526
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1527
+ params.push(limit);
1528
+ const rows = db2.prepare(`SELECT * FROM spans ${where} ORDER BY startTime DESC LIMIT ?`).all(...params);
1529
+ const spans = rows.map(rowToSpan);
1530
+ const otlp = (0, import_shared9.spansToOtlp)(spans, serviceName2);
1531
+ try {
1532
+ const res = await fetch(body.endpoint, {
1533
+ method: "POST",
1534
+ headers: sanitizeHeaders(body.headers),
1535
+ body: JSON.stringify(otlp),
1536
+ signal: AbortSignal.timeout(3e4)
1537
+ });
1538
+ if (!res.ok) {
1539
+ const text = await res.text().catch(() => "");
1540
+ return reply.status(502).send({
1541
+ error: "OTLP endpoint returned error",
1542
+ status: res.status,
1543
+ body: text.slice(0, 500)
1544
+ });
1545
+ }
1546
+ return reply.send({
1547
+ status: "ok",
1548
+ spanCount: spans.length,
1549
+ endpoint: body.endpoint
1550
+ });
1551
+ } catch (err) {
1552
+ return reply.status(502).send({
1553
+ error: "Failed to reach OTLP endpoint",
1554
+ message: err instanceof Error ? err.message : String(err)
1555
+ });
1556
+ }
1557
+ });
1558
+ }
1559
+
1560
+ // src/server.ts
1561
+ var rateLimitConfigs = {
1562
+ "POST:/v1/spans": { max: 300, windowMs: 6e4 },
1563
+ "POST:/v1/reset": { max: 5, windowMs: 6e4 },
1564
+ "POST:/v1/replay": { max: 30, windowMs: 6e4 },
1565
+ "POST:/v1/retention": { max: 10, windowMs: 6e4 },
1566
+ "POST:/v1/export/otlp/forward": { max: 20, windowMs: 6e4 },
1567
+ "GET:/v1/insights": { max: 60, windowMs: 6e4 },
1568
+ "GET:/v1/export/otlp": { max: 30, windowMs: 6e4 }
1569
+ };
1570
+ var rateLimitByIP = /* @__PURE__ */ new Map();
1571
+ var RetentionSchema = import_zod4.z.object({
1572
+ retentionDays: import_zod4.z.number().min(0).max(3650)
1573
+ });
1574
+ var ResetSchema = import_zod4.z.object({
1575
+ confirm: import_zod4.z.literal(true)
1576
+ });
1577
+ async function createServer(options = {}) {
1578
+ const port = options.port ?? import_shared10.DEFAULT_COLLECTOR_PORT;
1579
+ const host = options.host ?? "127.0.0.1";
1580
+ const app = (0, import_fastify.default)({
1581
+ logger: !options.quiet ? {
1582
+ transport: {
1583
+ target: "pino-pretty",
1584
+ options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" }
1585
+ }
1586
+ } : false,
1587
+ // Limit request body to 2MB to prevent abuse
1588
+ bodyLimit: 2 * 1024 * 1024
1589
+ });
1590
+ await app.register(import_cors.default, {
1591
+ origin: (origin, cb) => {
1592
+ if (!origin || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
1593
+ cb(null, true);
1594
+ } else {
1595
+ cb(new Error("Not allowed by CORS"), false);
1596
+ }
1597
+ },
1598
+ methods: ["GET", "POST", "OPTIONS"]
1599
+ });
1600
+ app.addHook("onRequest", async (request, reply) => {
1601
+ const pathname = request.url.split("?")[0].replace(/\/+$/, "");
1602
+ const key = `${request.method}:${pathname}`;
1603
+ const cfg = rateLimitConfigs[key];
1604
+ if (!cfg) return;
1605
+ const ip = request.ip;
1606
+ const ipKey = `${ip}:${key}`;
1607
+ const now = Date.now();
1608
+ let state = rateLimitByIP.get(ipKey);
1609
+ if (!state || now - state.windowStart > cfg.windowMs) {
1610
+ state = { count: 0, windowStart: now };
1611
+ rateLimitByIP.set(ipKey, state);
1612
+ }
1613
+ state.count++;
1614
+ if (state.count > cfg.max) {
1615
+ return reply.status(429).send({
1616
+ error: "Rate limit exceeded",
1617
+ retryAfterMs: cfg.windowMs - (now - state.windowStart)
1618
+ });
1619
+ }
1620
+ });
1621
+ const rateLimitCleanup = setInterval(() => {
1622
+ const now = Date.now();
1623
+ for (const [k, v] of rateLimitByIP) {
1624
+ if (now - v.windowStart > 12e4) rateLimitByIP.delete(k);
1625
+ }
1626
+ }, 6e4);
1627
+ rateLimitCleanup.unref();
1628
+ if (options.dashboardPath) {
1629
+ await app.register(import_static.default, {
1630
+ root: options.dashboardPath,
1631
+ prefix: "/",
1632
+ wildcard: true
1633
+ });
1634
+ app.setNotFoundHandler(async (_request, reply) => {
1635
+ return reply.sendFile("index.html");
1636
+ });
1637
+ }
1638
+ getDb();
1639
+ if (options.demo) {
1640
+ seedDemoData();
1641
+ }
1642
+ if (options.retentionDays && options.retentionDays > 0) {
1643
+ startRetentionSchedule(options.retentionDays);
1644
+ }
1645
+ initOtlpForwarder();
1646
+ await registerIngestRoute(app);
1647
+ await registerTraceRoutes(app);
1648
+ await registerStatsRoute(app);
1649
+ await registerSSERoute(app);
1650
+ await registerSessionsRoute(app);
1651
+ await registerDbInfoRoute(app);
1652
+ await registerInsightsRoute(app);
1653
+ await registerReplayRoute(app);
1654
+ await registerOtlpExportRoute(app);
1655
+ app.get("/health", async () => ({ status: "ok" }));
1656
+ app.post("/v1/reset", async (request, reply) => {
1657
+ const parsed = ResetSchema.safeParse(request.body);
1658
+ if (!parsed.success) {
1659
+ return reply.status(400).send({ error: "Must send { confirm: true } to reset database" });
1660
+ }
1661
+ resetDb();
1662
+ return reply.send({ status: "ok", message: "Data cleared" });
1663
+ });
1664
+ app.post("/v1/retention", async (request, reply) => {
1665
+ const parsed = RetentionSchema.safeParse(request.body);
1666
+ if (!parsed.success) {
1667
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.issues });
1668
+ }
1669
+ const deleted = enforceRetention(parsed.data.retentionDays);
1670
+ return reply.send({
1671
+ status: "ok",
1672
+ retentionDays: parsed.data.retentionDays,
1673
+ deletedSpans: deleted
1674
+ });
1675
+ });
1676
+ let isShuttingDown = false;
1677
+ const shutdown = async () => {
1678
+ if (isShuttingDown) return;
1679
+ isShuttingDown = true;
1680
+ clearInterval(rateLimitCleanup);
1681
+ await app.close();
1682
+ closeDb();
1683
+ };
1684
+ process.on("SIGINT", shutdown);
1685
+ process.on("SIGTERM", shutdown);
1686
+ app.addHook("onClose", async () => {
1687
+ process.off("SIGINT", shutdown);
1688
+ process.off("SIGTERM", shutdown);
1689
+ });
1690
+ return { app, port, host };
1691
+ }
1692
+ async function startServer(options = {}) {
1693
+ const { app, port, host } = await createServer(options);
1694
+ const address = await app.listen({ port, host });
1695
+ return address;
1696
+ }
1697
+ // Annotate the CommonJS export names for ESM import in node:
1698
+ 0 && (module.exports = {
1699
+ closeDb,
1700
+ createServer,
1701
+ enforceRetention,
1702
+ getDb,
1703
+ getOtlpEndpoint,
1704
+ resetDb,
1705
+ seedDemoData,
1706
+ startRetentionSchedule,
1707
+ startServer
1708
+ });
1709
+ //# sourceMappingURL=index.js.map