@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.d.mts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +1709 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1664 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +57 -0
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
|