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