lduck 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +19 -0
- package/README.md +120 -0
- package/dist/index.js +1131 -0
- package/dist/static/client.js +9 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { serve } from "@hono/node-server";
|
|
4
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
6
|
+
import { jsx, Fragment, jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { renderToString } from "react-dom/server";
|
|
9
|
+
import { zValidator } from "@hono/zod-validator";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { DuckDBInstance, DuckDBTimestampValue } from "@duckdb/node-api";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
const __vite_import_meta_env__ = {};
|
|
15
|
+
var ViteClient = () => {
|
|
16
|
+
if (__vite_import_meta_env__ && true) return /* @__PURE__ */ jsx(Fragment, {});
|
|
17
|
+
return /* @__PURE__ */ jsx("script", { type: "module", src: "/@vite/client" });
|
|
18
|
+
};
|
|
19
|
+
var ReactRefresh = () => {
|
|
20
|
+
if (__vite_import_meta_env__ && true) return /* @__PURE__ */ jsx(Fragment, {});
|
|
21
|
+
const refreshScript = `
|
|
22
|
+
import RefreshRuntime from '/@react-refresh';
|
|
23
|
+
RefreshRuntime.injectIntoGlobalHook(window);
|
|
24
|
+
window.$RefreshReg$ = () => {};
|
|
25
|
+
window.$RefreshSig$ = () => (type) => type;
|
|
26
|
+
window.__vite_plugin_react_preamble_installed__ = true;
|
|
27
|
+
`;
|
|
28
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
29
|
+
/* @__PURE__ */ jsx("script", { type: "module", src: "/@react-refresh" }),
|
|
30
|
+
/* @__PURE__ */ jsx("script", { type: "module", dangerouslySetInnerHTML: { __html: refreshScript } })
|
|
31
|
+
] });
|
|
32
|
+
};
|
|
33
|
+
function createApp(scriptSrc, setup) {
|
|
34
|
+
const app = new Hono();
|
|
35
|
+
setup?.(app);
|
|
36
|
+
app.get("*", (c) => {
|
|
37
|
+
const html = renderToString(
|
|
38
|
+
/* @__PURE__ */ jsxs("html", { lang: "en", style: { margin: 0, padding: 0, height: "100%" }, children: [
|
|
39
|
+
/* @__PURE__ */ jsxs("head", { children: [
|
|
40
|
+
/* @__PURE__ */ jsx("meta", { charSet: "UTF-8" }),
|
|
41
|
+
/* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width,initial-scale=1.0" }),
|
|
42
|
+
/* @__PURE__ */ jsx("title", { children: "lduck" }),
|
|
43
|
+
/* @__PURE__ */ jsx(ViteClient, {}),
|
|
44
|
+
/* @__PURE__ */ jsx(ReactRefresh, {})
|
|
45
|
+
] }),
|
|
46
|
+
/* @__PURE__ */ jsxs("body", { style: { margin: 0, padding: 0, height: "100%" }, children: [
|
|
47
|
+
/* @__PURE__ */ jsx("div", { id: "root" }),
|
|
48
|
+
/* @__PURE__ */ jsx("script", { type: "module", src: scriptSrc })
|
|
49
|
+
] })
|
|
50
|
+
] })
|
|
51
|
+
);
|
|
52
|
+
return c.html(`<!DOCTYPE html>${html}`);
|
|
53
|
+
});
|
|
54
|
+
return app;
|
|
55
|
+
}
|
|
56
|
+
const logQuerySchema = z.object({
|
|
57
|
+
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
|
|
58
|
+
service: z.string().optional(),
|
|
59
|
+
source: z.string().optional(),
|
|
60
|
+
search: z.string().optional(),
|
|
61
|
+
startTime: z.coerce.date().optional(),
|
|
62
|
+
endTime: z.coerce.date().optional(),
|
|
63
|
+
limit: z.coerce.number().int().min(1).max(1e4).default(200),
|
|
64
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
65
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
66
|
+
jsonFilters: z.string().optional()
|
|
67
|
+
});
|
|
68
|
+
const statsQuerySchema = z.object({
|
|
69
|
+
source: z.string().optional()
|
|
70
|
+
});
|
|
71
|
+
const sqlQuerySchema = z.object({
|
|
72
|
+
sql: z.string().min(1).max(1e4)
|
|
73
|
+
});
|
|
74
|
+
const exportSchema = z.object({
|
|
75
|
+
format: z.enum(["csv", "json"]),
|
|
76
|
+
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
|
|
77
|
+
service: z.string().optional(),
|
|
78
|
+
source: z.string().optional(),
|
|
79
|
+
search: z.string().optional(),
|
|
80
|
+
startTime: z.coerce.date().optional(),
|
|
81
|
+
endTime: z.coerce.date().optional()
|
|
82
|
+
});
|
|
83
|
+
const ingestBodySchema = z.union([
|
|
84
|
+
z.record(z.string(), z.unknown()),
|
|
85
|
+
z.array(z.record(z.string(), z.unknown()))
|
|
86
|
+
]);
|
|
87
|
+
const facetQuerySchema = z.object({
|
|
88
|
+
field: z.string(),
|
|
89
|
+
jsonPath: z.string().optional(),
|
|
90
|
+
level: z.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"]).optional(),
|
|
91
|
+
service: z.string().optional(),
|
|
92
|
+
source: z.string().optional(),
|
|
93
|
+
search: z.string().optional(),
|
|
94
|
+
startTime: z.coerce.date().optional(),
|
|
95
|
+
endTime: z.coerce.date().optional(),
|
|
96
|
+
jsonFilters: z.string().optional()
|
|
97
|
+
});
|
|
98
|
+
function validationHook(result, c) {
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
return c.json(
|
|
101
|
+
{
|
|
102
|
+
error: {
|
|
103
|
+
code: "VALIDATION_ERROR",
|
|
104
|
+
message: "Invalid request parameters",
|
|
105
|
+
details: result.error?.issues
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
400
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function serializeLogEntry(log) {
|
|
113
|
+
return {
|
|
114
|
+
_id: String(log._id),
|
|
115
|
+
_ingested: log._ingested.toISOString(),
|
|
116
|
+
_raw: log._raw,
|
|
117
|
+
timestamp: log.timestamp?.toISOString() ?? null,
|
|
118
|
+
level: log.level,
|
|
119
|
+
message: log.message,
|
|
120
|
+
service: log.service,
|
|
121
|
+
trace_id: log.trace_id,
|
|
122
|
+
host: log.host,
|
|
123
|
+
duration_ms: log.duration_ms,
|
|
124
|
+
source: log.source
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function serializeStats$1(stats) {
|
|
128
|
+
return {
|
|
129
|
+
total: stats.total,
|
|
130
|
+
byLevel: stats.byLevel,
|
|
131
|
+
errorRate: stats.errorRate,
|
|
132
|
+
timeRange: {
|
|
133
|
+
min: stats.timeRange.min?.toISOString() ?? null,
|
|
134
|
+
max: stats.timeRange.max?.toISOString() ?? null
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function createApiApp(db, ingester) {
|
|
139
|
+
const startTime = Date.now();
|
|
140
|
+
const app = new Hono().basePath("/api").get("/health", (c) => {
|
|
141
|
+
const uptime = (Date.now() - startTime) / 1e3;
|
|
142
|
+
return c.json({ status: "ok", uptime });
|
|
143
|
+
}).get("/schema", async (c) => {
|
|
144
|
+
try {
|
|
145
|
+
const schema = await db.getSchema();
|
|
146
|
+
return c.json(schema);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return c.json(
|
|
149
|
+
{
|
|
150
|
+
error: {
|
|
151
|
+
code: "INTERNAL_ERROR",
|
|
152
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
500
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}).get("/stats", zValidator("query", statsQuerySchema, validationHook), async (c) => {
|
|
159
|
+
try {
|
|
160
|
+
const { source } = c.req.valid("query");
|
|
161
|
+
const stats = await db.getStats(source ? { source } : void 0);
|
|
162
|
+
return c.json(serializeStats$1(stats));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return c.json(
|
|
165
|
+
{
|
|
166
|
+
error: {
|
|
167
|
+
code: "INTERNAL_ERROR",
|
|
168
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
500
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}).get("/logs", zValidator("query", logQuerySchema, validationHook), async (c) => {
|
|
175
|
+
try {
|
|
176
|
+
const params = c.req.valid("query");
|
|
177
|
+
let jsonFilters;
|
|
178
|
+
if (params.jsonFilters) {
|
|
179
|
+
try {
|
|
180
|
+
jsonFilters = JSON.parse(params.jsonFilters);
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const queryParams = {
|
|
185
|
+
level: params.level,
|
|
186
|
+
service: params.service,
|
|
187
|
+
source: params.source,
|
|
188
|
+
search: params.search,
|
|
189
|
+
startTime: params.startTime,
|
|
190
|
+
endTime: params.endTime,
|
|
191
|
+
limit: params.limit,
|
|
192
|
+
offset: params.offset,
|
|
193
|
+
order: params.order,
|
|
194
|
+
jsonFilters
|
|
195
|
+
};
|
|
196
|
+
const result = await db.queryLogs(queryParams);
|
|
197
|
+
return c.json({
|
|
198
|
+
logs: result.logs.map(serializeLogEntry),
|
|
199
|
+
total: result.total
|
|
200
|
+
});
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return c.json(
|
|
203
|
+
{
|
|
204
|
+
error: {
|
|
205
|
+
code: "QUERY_ERROR",
|
|
206
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
500
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}).post("/query", zValidator("json", sqlQuerySchema, validationHook), async (c) => {
|
|
213
|
+
try {
|
|
214
|
+
const { sql } = c.req.valid("json");
|
|
215
|
+
const result = await db.executeQuery(sql);
|
|
216
|
+
return c.json(result);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
219
|
+
if (message.startsWith("Forbidden SQL:")) {
|
|
220
|
+
return c.json(
|
|
221
|
+
{
|
|
222
|
+
error: {
|
|
223
|
+
code: "FORBIDDEN_SQL",
|
|
224
|
+
message
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
403
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return c.json(
|
|
231
|
+
{
|
|
232
|
+
error: {
|
|
233
|
+
code: "QUERY_ERROR",
|
|
234
|
+
message
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
500
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}).post("/export", zValidator("json", exportSchema, validationHook), async (c) => {
|
|
241
|
+
try {
|
|
242
|
+
const params = c.req.valid("json");
|
|
243
|
+
const queryParams = {
|
|
244
|
+
level: params.level,
|
|
245
|
+
service: params.service,
|
|
246
|
+
source: params.source,
|
|
247
|
+
search: params.search,
|
|
248
|
+
startTime: params.startTime,
|
|
249
|
+
endTime: params.endTime,
|
|
250
|
+
limit: 1e4,
|
|
251
|
+
// Export up to max
|
|
252
|
+
offset: 0,
|
|
253
|
+
order: "desc"
|
|
254
|
+
};
|
|
255
|
+
const stream = await db.exportLogs(queryParams, params.format);
|
|
256
|
+
const contentType = params.format === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8";
|
|
257
|
+
const ext = params.format === "csv" ? "csv" : "json";
|
|
258
|
+
return new Response(stream, {
|
|
259
|
+
status: 200,
|
|
260
|
+
headers: {
|
|
261
|
+
"Content-Type": contentType,
|
|
262
|
+
"Content-Disposition": `attachment; filename="logs-export.${ext}"`
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return c.json(
|
|
267
|
+
{
|
|
268
|
+
error: {
|
|
269
|
+
code: "INTERNAL_ERROR",
|
|
270
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
500
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}).post("/ingest", zValidator("json", ingestBodySchema, validationHook), (c) => {
|
|
277
|
+
if (!ingester) {
|
|
278
|
+
return c.json(
|
|
279
|
+
{
|
|
280
|
+
error: {
|
|
281
|
+
code: "NOT_AVAILABLE",
|
|
282
|
+
message: "Ingestion not available"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
503
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const body = c.req.valid("json");
|
|
289
|
+
const items = Array.isArray(body) ? body : [body];
|
|
290
|
+
const lines = items.map((item) => JSON.stringify(item));
|
|
291
|
+
ingester.ingestLines(lines);
|
|
292
|
+
return c.json({ accepted: lines.length });
|
|
293
|
+
}).get("/facets", zValidator("query", facetQuerySchema, validationHook), async (c) => {
|
|
294
|
+
try {
|
|
295
|
+
const params = c.req.valid("query");
|
|
296
|
+
let jsonFilters;
|
|
297
|
+
if (params.jsonFilters) {
|
|
298
|
+
try {
|
|
299
|
+
jsonFilters = JSON.parse(params.jsonFilters);
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const filters = {
|
|
304
|
+
level: params.level,
|
|
305
|
+
service: params.service,
|
|
306
|
+
source: params.source,
|
|
307
|
+
search: params.search,
|
|
308
|
+
startTime: params.startTime,
|
|
309
|
+
endTime: params.endTime,
|
|
310
|
+
jsonFilters
|
|
311
|
+
};
|
|
312
|
+
const distribution = await db.getFacetDistribution(
|
|
313
|
+
params.field,
|
|
314
|
+
params.jsonPath ?? null,
|
|
315
|
+
filters
|
|
316
|
+
);
|
|
317
|
+
return c.json(distribution);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return c.json(
|
|
320
|
+
{
|
|
321
|
+
error: {
|
|
322
|
+
code: "QUERY_ERROR",
|
|
323
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
500
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return app;
|
|
331
|
+
}
|
|
332
|
+
new Hono().basePath("/api").get(
|
|
333
|
+
"/health",
|
|
334
|
+
(c) => c.json({ status: "ok", uptime: 0 })
|
|
335
|
+
);
|
|
336
|
+
const CREATE_TABLE_SQL = `
|
|
337
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
338
|
+
_id BIGINT PRIMARY KEY,
|
|
339
|
+
_ingested TIMESTAMP DEFAULT current_timestamp,
|
|
340
|
+
_raw JSON,
|
|
341
|
+
timestamp TIMESTAMP,
|
|
342
|
+
level VARCHAR,
|
|
343
|
+
message VARCHAR,
|
|
344
|
+
service VARCHAR,
|
|
345
|
+
trace_id VARCHAR,
|
|
346
|
+
host VARCHAR,
|
|
347
|
+
duration_ms DOUBLE,
|
|
348
|
+
source VARCHAR DEFAULT 'default'
|
|
349
|
+
);
|
|
350
|
+
`;
|
|
351
|
+
const CREATE_INDEXES_SQL = [
|
|
352
|
+
"CREATE INDEX IF NOT EXISTS idx_level ON logs(level);",
|
|
353
|
+
"CREATE INDEX IF NOT EXISTS idx_ts ON logs(timestamp DESC);",
|
|
354
|
+
"CREATE INDEX IF NOT EXISTS idx_service ON logs(service);",
|
|
355
|
+
"CREATE INDEX IF NOT EXISTS idx_source ON logs(source);"
|
|
356
|
+
];
|
|
357
|
+
const LOG_COLUMNS = "_id, _ingested, _raw, timestamp, level, message, service, trace_id, host, duration_ms, source";
|
|
358
|
+
const ALLOWED_SQL_KEYWORDS = ["SELECT", "WITH", "EXPLAIN"];
|
|
359
|
+
class LogDatabase {
|
|
360
|
+
instance = null;
|
|
361
|
+
connection = null;
|
|
362
|
+
// -------------------------------------------------------------------------
|
|
363
|
+
// Lifecycle
|
|
364
|
+
// -------------------------------------------------------------------------
|
|
365
|
+
async initialize(dbPath) {
|
|
366
|
+
if (dbPath === ":memory:") {
|
|
367
|
+
this.instance = await DuckDBInstance.create();
|
|
368
|
+
} else {
|
|
369
|
+
this.instance = await DuckDBInstance.create(dbPath);
|
|
370
|
+
}
|
|
371
|
+
this.connection = await this.instance.connect();
|
|
372
|
+
await this.connection.run(CREATE_TABLE_SQL);
|
|
373
|
+
for (const sql of CREATE_INDEXES_SQL) {
|
|
374
|
+
await this.connection.run(sql);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async close() {
|
|
378
|
+
if (this.connection) {
|
|
379
|
+
this.connection.closeSync();
|
|
380
|
+
this.connection = null;
|
|
381
|
+
}
|
|
382
|
+
if (this.instance) {
|
|
383
|
+
this.instance.closeSync();
|
|
384
|
+
this.instance = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
getConnection() {
|
|
388
|
+
if (!this.connection) {
|
|
389
|
+
throw new Error("Database is not initialized or has been closed");
|
|
390
|
+
}
|
|
391
|
+
return this.connection;
|
|
392
|
+
}
|
|
393
|
+
// -------------------------------------------------------------------------
|
|
394
|
+
// Batch INSERT
|
|
395
|
+
// -------------------------------------------------------------------------
|
|
396
|
+
async insertBatch(logs) {
|
|
397
|
+
if (logs.length === 0) return;
|
|
398
|
+
const conn = this.getConnection();
|
|
399
|
+
const appender = await conn.createAppender("logs");
|
|
400
|
+
for (const log of logs) {
|
|
401
|
+
appender.appendBigInt(log._id);
|
|
402
|
+
appender.appendTimestamp(dateToTimestamp(log._ingested));
|
|
403
|
+
appender.appendVarchar(log._raw);
|
|
404
|
+
appendNullableTimestamp(appender, log.timestamp);
|
|
405
|
+
appendNullableVarchar(appender, log.level);
|
|
406
|
+
appendNullableVarchar(appender, log.message);
|
|
407
|
+
appendNullableVarchar(appender, log.service);
|
|
408
|
+
appendNullableVarchar(appender, log.trace_id);
|
|
409
|
+
appendNullableVarchar(appender, log.host);
|
|
410
|
+
appendNullableDouble(appender, log.duration_ms);
|
|
411
|
+
appender.appendVarchar(log.source);
|
|
412
|
+
appender.endRow();
|
|
413
|
+
}
|
|
414
|
+
appender.flushSync();
|
|
415
|
+
appender.closeSync();
|
|
416
|
+
}
|
|
417
|
+
// -------------------------------------------------------------------------
|
|
418
|
+
// Eviction
|
|
419
|
+
// -------------------------------------------------------------------------
|
|
420
|
+
async evictOldRows(maxRows) {
|
|
421
|
+
const conn = this.getConnection();
|
|
422
|
+
await conn.run(
|
|
423
|
+
`DELETE FROM logs WHERE _id <= (SELECT MAX(_id) - ${maxRows} FROM logs)`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
// -------------------------------------------------------------------------
|
|
427
|
+
// Query
|
|
428
|
+
// -------------------------------------------------------------------------
|
|
429
|
+
async queryLogs(params) {
|
|
430
|
+
const conn = this.getConnection();
|
|
431
|
+
const whereClause = buildWhereClause(params);
|
|
432
|
+
const countReader = await conn.runAndReadAll(
|
|
433
|
+
`SELECT COUNT(*) FROM logs ${whereClause}`
|
|
434
|
+
);
|
|
435
|
+
const total = Number(countReader.getRowsJS()[0][0]);
|
|
436
|
+
const orderDir = params.order === "asc" ? "ASC" : "DESC";
|
|
437
|
+
const dataReader = await conn.runAndReadAll(
|
|
438
|
+
`SELECT ${LOG_COLUMNS} FROM logs ${whereClause}
|
|
439
|
+
ORDER BY timestamp ${orderDir} NULLS LAST, _id ${orderDir}
|
|
440
|
+
LIMIT ${params.limit} OFFSET ${params.offset}`
|
|
441
|
+
);
|
|
442
|
+
const logs = dataReader.getRowsJS().map(rowToLogEntry);
|
|
443
|
+
return { logs, total };
|
|
444
|
+
}
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
// Statistics
|
|
447
|
+
// -------------------------------------------------------------------------
|
|
448
|
+
async getStats(params) {
|
|
449
|
+
const conn = this.getConnection();
|
|
450
|
+
const whereClause = params?.source ? `WHERE source = '${escapeSql(params.source)}'` : "";
|
|
451
|
+
const totalReader = await conn.runAndReadAll(
|
|
452
|
+
`SELECT COUNT(*) FROM logs ${whereClause}`
|
|
453
|
+
);
|
|
454
|
+
const total = Number(totalReader.getRowsJS()[0][0]);
|
|
455
|
+
if (total === 0) {
|
|
456
|
+
return {
|
|
457
|
+
total: 0,
|
|
458
|
+
byLevel: {},
|
|
459
|
+
errorRate: 0,
|
|
460
|
+
timeRange: { min: null, max: null }
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const levelWhereClause = whereClause ? `${whereClause} AND level IS NOT NULL` : "WHERE level IS NOT NULL";
|
|
464
|
+
const levelReader = await conn.runAndReadAll(
|
|
465
|
+
`SELECT level, COUNT(*) as cnt FROM logs ${levelWhereClause} GROUP BY level ORDER BY cnt DESC`
|
|
466
|
+
);
|
|
467
|
+
const byLevel = {};
|
|
468
|
+
for (const row of levelReader.getRowsJS()) {
|
|
469
|
+
byLevel[row[0]] = Number(row[1]);
|
|
470
|
+
}
|
|
471
|
+
const errorCount = (byLevel["ERROR"] ?? 0) + (byLevel["FATAL"] ?? 0);
|
|
472
|
+
const errorRate = errorCount / total;
|
|
473
|
+
const timeReader = await conn.runAndReadAll(
|
|
474
|
+
`SELECT MIN(timestamp), MAX(timestamp) FROM logs ${whereClause}`
|
|
475
|
+
);
|
|
476
|
+
const timeRow = timeReader.getRowsJS()[0];
|
|
477
|
+
return {
|
|
478
|
+
total,
|
|
479
|
+
byLevel,
|
|
480
|
+
errorRate,
|
|
481
|
+
timeRange: {
|
|
482
|
+
min: timeRow[0] !== null ? jsToDate(timeRow[0]) : null,
|
|
483
|
+
max: timeRow[1] !== null ? jsToDate(timeRow[1]) : null
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// -------------------------------------------------------------------------
|
|
488
|
+
// Facets
|
|
489
|
+
// -------------------------------------------------------------------------
|
|
490
|
+
async getFacetDistribution(field, jsonPath, filters) {
|
|
491
|
+
const conn = this.getConnection();
|
|
492
|
+
const columnExpr = jsonPath !== null ? `CAST(json_extract(_raw, '${escapeSql("$." + jsonPath)}') AS VARCHAR)` : `"${field}"`;
|
|
493
|
+
const conditions = buildFilterConditions(filters);
|
|
494
|
+
conditions.push(`${columnExpr} IS NOT NULL`);
|
|
495
|
+
const whereClause = `WHERE ${conditions.join(" AND ")}`;
|
|
496
|
+
const reader = await conn.runAndReadAll(
|
|
497
|
+
`SELECT ${columnExpr} as val, COUNT(*) as cnt FROM logs ${whereClause} GROUP BY val ORDER BY cnt DESC, val ASC`
|
|
498
|
+
);
|
|
499
|
+
const values = reader.getRowsJS().map((row) => {
|
|
500
|
+
let value = String(row[0]);
|
|
501
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
502
|
+
value = value.slice(1, -1);
|
|
503
|
+
}
|
|
504
|
+
return { value, count: Number(row[1]) };
|
|
505
|
+
});
|
|
506
|
+
return { field, values };
|
|
507
|
+
}
|
|
508
|
+
// -------------------------------------------------------------------------
|
|
509
|
+
// Custom SQL Execution
|
|
510
|
+
// -------------------------------------------------------------------------
|
|
511
|
+
async executeQuery(sql) {
|
|
512
|
+
const conn = this.getConnection();
|
|
513
|
+
const firstKeyword = sql.trimStart().split(/\s+/)[0].toUpperCase();
|
|
514
|
+
if (!ALLOWED_SQL_KEYWORDS.includes(firstKeyword)) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Forbidden SQL: only SELECT, WITH, and EXPLAIN statements are allowed. Got: ${firstKeyword}`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const reader = await conn.runAndReadAll(sql);
|
|
520
|
+
const columns = reader.columnNames();
|
|
521
|
+
const rows = reader.getRowsJS().map(
|
|
522
|
+
(row) => row.map((val) => typeof val === "bigint" ? Number(val) : val)
|
|
523
|
+
);
|
|
524
|
+
return { columns, rows };
|
|
525
|
+
}
|
|
526
|
+
// -------------------------------------------------------------------------
|
|
527
|
+
// Export
|
|
528
|
+
// -------------------------------------------------------------------------
|
|
529
|
+
async exportLogs(params, format) {
|
|
530
|
+
const conn = this.getConnection();
|
|
531
|
+
const whereClause = buildWhereClause(params);
|
|
532
|
+
const orderDir = params.order === "asc" ? "ASC" : "DESC";
|
|
533
|
+
const reader = await conn.runAndReadAll(
|
|
534
|
+
`SELECT ${LOG_COLUMNS} FROM logs ${whereClause}
|
|
535
|
+
ORDER BY timestamp ${orderDir} NULLS LAST, _id ${orderDir}
|
|
536
|
+
LIMIT ${params.limit} OFFSET ${params.offset}`
|
|
537
|
+
);
|
|
538
|
+
const columns = reader.columnNames();
|
|
539
|
+
const rows = reader.getRowsJS();
|
|
540
|
+
const encoder = new TextEncoder();
|
|
541
|
+
if (format === "csv") {
|
|
542
|
+
return new ReadableStream({
|
|
543
|
+
start(controller) {
|
|
544
|
+
controller.enqueue(encoder.encode(columns.join(",") + "\n"));
|
|
545
|
+
for (const row of rows) {
|
|
546
|
+
controller.enqueue(
|
|
547
|
+
encoder.encode(row.map(csvEscape).join(",") + "\n")
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
controller.close();
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const jsonRows = rows.map((row) => {
|
|
555
|
+
const obj = {};
|
|
556
|
+
for (let i = 0; i < columns.length; i++) {
|
|
557
|
+
let val = row[i];
|
|
558
|
+
if (typeof val === "bigint") val = Number(val);
|
|
559
|
+
if (columns[i] === "_raw" && typeof val === "string") {
|
|
560
|
+
try {
|
|
561
|
+
val = JSON.parse(val);
|
|
562
|
+
} catch {
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
obj[columns[i]] = val;
|
|
566
|
+
}
|
|
567
|
+
return obj;
|
|
568
|
+
});
|
|
569
|
+
return new ReadableStream({
|
|
570
|
+
start(controller) {
|
|
571
|
+
controller.enqueue(encoder.encode(JSON.stringify(jsonRows)));
|
|
572
|
+
controller.close();
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// -------------------------------------------------------------------------
|
|
577
|
+
// Schema
|
|
578
|
+
// -------------------------------------------------------------------------
|
|
579
|
+
async getSchema() {
|
|
580
|
+
const conn = this.getConnection();
|
|
581
|
+
const reader = await conn.runAndReadAll(
|
|
582
|
+
"SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'logs' ORDER BY ordinal_position"
|
|
583
|
+
);
|
|
584
|
+
return reader.getRowsJS().map((row) => ({
|
|
585
|
+
name: row[0],
|
|
586
|
+
type: row[1],
|
|
587
|
+
nullable: row[2] === "YES"
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function dateToTimestamp(date) {
|
|
592
|
+
return new DuckDBTimestampValue(BigInt(date.getTime()) * 1000n);
|
|
593
|
+
}
|
|
594
|
+
function jsToDate(value) {
|
|
595
|
+
if (value instanceof Date) return value;
|
|
596
|
+
if (typeof value === "string" || typeof value === "number") return new Date(value);
|
|
597
|
+
return new Date(String(value));
|
|
598
|
+
}
|
|
599
|
+
function escapeSql(value) {
|
|
600
|
+
return value.replace(/'/g, "''");
|
|
601
|
+
}
|
|
602
|
+
function csvEscape(value) {
|
|
603
|
+
if (value === null || value === void 0) return "";
|
|
604
|
+
const str = String(value);
|
|
605
|
+
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
|
606
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
607
|
+
}
|
|
608
|
+
return str;
|
|
609
|
+
}
|
|
610
|
+
function appendNullableVarchar(appender, value) {
|
|
611
|
+
if (value !== null) {
|
|
612
|
+
appender.appendVarchar(value);
|
|
613
|
+
} else {
|
|
614
|
+
appender.appendNull();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function appendNullableTimestamp(appender, value) {
|
|
618
|
+
if (value !== null) {
|
|
619
|
+
appender.appendTimestamp(dateToTimestamp(value));
|
|
620
|
+
} else {
|
|
621
|
+
appender.appendNull();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function appendNullableDouble(appender, value) {
|
|
625
|
+
if (value !== null) {
|
|
626
|
+
appender.appendDouble(value);
|
|
627
|
+
} else {
|
|
628
|
+
appender.appendNull();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function buildFilterConditions(params) {
|
|
632
|
+
const conditions = [];
|
|
633
|
+
if (params.level) {
|
|
634
|
+
conditions.push(`level = '${escapeSql(params.level)}'`);
|
|
635
|
+
}
|
|
636
|
+
if (params.service) {
|
|
637
|
+
conditions.push(`service = '${escapeSql(params.service)}'`);
|
|
638
|
+
}
|
|
639
|
+
if (params.source) {
|
|
640
|
+
conditions.push(`source = '${escapeSql(params.source)}'`);
|
|
641
|
+
}
|
|
642
|
+
if (params.search) {
|
|
643
|
+
conditions.push(`message ILIKE '%${escapeSql(params.search)}%'`);
|
|
644
|
+
}
|
|
645
|
+
if (params.startTime) {
|
|
646
|
+
conditions.push(`timestamp >= '${params.startTime.toISOString()}'`);
|
|
647
|
+
}
|
|
648
|
+
if (params.endTime) {
|
|
649
|
+
conditions.push(`timestamp <= '${params.endTime.toISOString()}'`);
|
|
650
|
+
}
|
|
651
|
+
if (params.jsonFilters) {
|
|
652
|
+
for (const [jsonPath, value] of Object.entries(params.jsonFilters)) {
|
|
653
|
+
const expr = `CAST(json_extract(_raw, '${escapeSql("$." + jsonPath)}') AS VARCHAR)`;
|
|
654
|
+
conditions.push(`${expr} = '${escapeSql(value)}'`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return conditions;
|
|
658
|
+
}
|
|
659
|
+
function buildWhereClause(params) {
|
|
660
|
+
const conditions = buildFilterConditions(params);
|
|
661
|
+
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
662
|
+
}
|
|
663
|
+
function rowToLogEntry(row) {
|
|
664
|
+
return {
|
|
665
|
+
_id: BigInt(row[0]),
|
|
666
|
+
_ingested: jsToDate(row[1]),
|
|
667
|
+
_raw: typeof row[2] === "string" ? JSON.parse(row[2]) : row[2],
|
|
668
|
+
timestamp: row[3] !== null ? jsToDate(row[3]) : null,
|
|
669
|
+
level: row[4],
|
|
670
|
+
message: row[5],
|
|
671
|
+
service: row[6],
|
|
672
|
+
trace_id: row[7],
|
|
673
|
+
host: row[8],
|
|
674
|
+
duration_ms: row[9],
|
|
675
|
+
source: row[10]
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const FIELD_MAPPINGS = {
|
|
679
|
+
timestamp: ["timestamp", "ts", "time", "@timestamp", "datetime"],
|
|
680
|
+
level: ["level", "severity", "loglevel", "lvl", "priority"],
|
|
681
|
+
message: ["message", "msg", "body", "text"],
|
|
682
|
+
service: ["service", "svc", "app", "component", "logger"],
|
|
683
|
+
trace_id: ["trace_id", "traceId", "request_id", "correlation_id"]
|
|
684
|
+
};
|
|
685
|
+
const REVERSE_FIELD_MAP = (() => {
|
|
686
|
+
const map = /* @__PURE__ */ new Map();
|
|
687
|
+
for (const [canonical, aliases] of Object.entries(FIELD_MAPPINGS)) {
|
|
688
|
+
for (const alias of aliases) {
|
|
689
|
+
map.set(alias, canonical);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return map;
|
|
693
|
+
})();
|
|
694
|
+
function resolveField(fieldName) {
|
|
695
|
+
return REVERSE_FIELD_MAP.get(fieldName) ?? null;
|
|
696
|
+
}
|
|
697
|
+
class Ingester {
|
|
698
|
+
db;
|
|
699
|
+
options;
|
|
700
|
+
emitter = new EventEmitter();
|
|
701
|
+
nextId = 1n;
|
|
702
|
+
buffer = [];
|
|
703
|
+
flushTimer = null;
|
|
704
|
+
rl = null;
|
|
705
|
+
stopped = false;
|
|
706
|
+
flushing = null;
|
|
707
|
+
constructor(db, options) {
|
|
708
|
+
this.db = db;
|
|
709
|
+
this.options = options;
|
|
710
|
+
}
|
|
711
|
+
// -------------------------------------------------------------------------
|
|
712
|
+
// Public API: IngesterService interface
|
|
713
|
+
// -------------------------------------------------------------------------
|
|
714
|
+
/**
|
|
715
|
+
* Start the periodic flush timer without binding to a readable stream.
|
|
716
|
+
* Use this when ingesting logs via `ingestLines()` (e.g. HTTP endpoint).
|
|
717
|
+
*/
|
|
718
|
+
startTimer() {
|
|
719
|
+
this.stopped = false;
|
|
720
|
+
this.resetFlushTimer();
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Feed JSON lines directly (without a stream).
|
|
724
|
+
* Each string is processed through the same normalization pipeline as stdin.
|
|
725
|
+
*/
|
|
726
|
+
ingestLines(lines) {
|
|
727
|
+
for (const line of lines) {
|
|
728
|
+
this.handleLine(line);
|
|
729
|
+
}
|
|
730
|
+
if (this.flushTimer === null && !this.stopped) {
|
|
731
|
+
this.resetFlushTimer();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
start(input) {
|
|
735
|
+
this.startTimer();
|
|
736
|
+
this.rl = createInterface({
|
|
737
|
+
input,
|
|
738
|
+
crlfDelay: Infinity
|
|
739
|
+
});
|
|
740
|
+
this.rl.on("line", (line) => {
|
|
741
|
+
this.handleLine(line);
|
|
742
|
+
});
|
|
743
|
+
this.rl.on("close", () => {
|
|
744
|
+
this.flushBuffer();
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async stop() {
|
|
748
|
+
if (this.stopped) return;
|
|
749
|
+
this.stopped = true;
|
|
750
|
+
if (this.flushTimer !== null) {
|
|
751
|
+
clearTimeout(this.flushTimer);
|
|
752
|
+
this.flushTimer = null;
|
|
753
|
+
}
|
|
754
|
+
if (this.rl) {
|
|
755
|
+
this.rl.close();
|
|
756
|
+
this.rl = null;
|
|
757
|
+
}
|
|
758
|
+
if (this.flushing) {
|
|
759
|
+
await this.flushing;
|
|
760
|
+
}
|
|
761
|
+
await this.flushBuffer();
|
|
762
|
+
}
|
|
763
|
+
on(event, listener) {
|
|
764
|
+
this.emitter.on(event, listener);
|
|
765
|
+
}
|
|
766
|
+
// -------------------------------------------------------------------------
|
|
767
|
+
// Line processing
|
|
768
|
+
// -------------------------------------------------------------------------
|
|
769
|
+
handleLine(line) {
|
|
770
|
+
const trimmed = line.trim();
|
|
771
|
+
if (trimmed === "") return;
|
|
772
|
+
let parsed;
|
|
773
|
+
try {
|
|
774
|
+
parsed = JSON.parse(trimmed);
|
|
775
|
+
} catch {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const normalized = this.normalize(parsed, trimmed);
|
|
782
|
+
this.buffer.push(normalized);
|
|
783
|
+
if (this.buffer.length >= this.options.batchSize) {
|
|
784
|
+
this.flushBuffer();
|
|
785
|
+
} else if (this.flushTimer === null && !this.stopped) {
|
|
786
|
+
this.resetFlushTimer();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// -------------------------------------------------------------------------
|
|
790
|
+
// Normalization
|
|
791
|
+
// -------------------------------------------------------------------------
|
|
792
|
+
normalize(obj, raw) {
|
|
793
|
+
const resolved = {};
|
|
794
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
795
|
+
const canonical = resolveField(key);
|
|
796
|
+
if (canonical !== null && !(canonical in resolved)) {
|
|
797
|
+
resolved[canonical] = value;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
let timestamp = null;
|
|
801
|
+
if (resolved.timestamp !== void 0 && resolved.timestamp !== null) {
|
|
802
|
+
const d = new Date(resolved.timestamp);
|
|
803
|
+
if (!isNaN(d.getTime())) {
|
|
804
|
+
timestamp = d;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
let duration_ms = null;
|
|
808
|
+
if (obj.duration_ms !== void 0 && obj.duration_ms !== null) {
|
|
809
|
+
const n = Number(obj.duration_ms);
|
|
810
|
+
if (!isNaN(n)) {
|
|
811
|
+
duration_ms = n;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const source = typeof obj.source === "string" && obj.source !== "" ? obj.source : this.options.defaultSource;
|
|
815
|
+
const host = typeof obj.host === "string" && obj.host !== "" ? obj.host : null;
|
|
816
|
+
const id = this.nextId++;
|
|
817
|
+
return {
|
|
818
|
+
_id: id,
|
|
819
|
+
_ingested: /* @__PURE__ */ new Date(),
|
|
820
|
+
_raw: raw,
|
|
821
|
+
timestamp,
|
|
822
|
+
level: typeof resolved.level === "string" ? resolved.level : null,
|
|
823
|
+
message: typeof resolved.message === "string" ? resolved.message : null,
|
|
824
|
+
service: typeof resolved.service === "string" ? resolved.service : null,
|
|
825
|
+
trace_id: typeof resolved.trace_id === "string" ? resolved.trace_id : null,
|
|
826
|
+
host,
|
|
827
|
+
duration_ms,
|
|
828
|
+
source
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
// -------------------------------------------------------------------------
|
|
832
|
+
// Buffer flush
|
|
833
|
+
// -------------------------------------------------------------------------
|
|
834
|
+
flushBuffer() {
|
|
835
|
+
if (this.buffer.length === 0) return;
|
|
836
|
+
const batch = this.buffer.slice();
|
|
837
|
+
this.buffer = [];
|
|
838
|
+
this.resetFlushTimer();
|
|
839
|
+
const prev = this.flushing ?? Promise.resolve();
|
|
840
|
+
this.flushing = prev.then(() => this.doFlush(batch));
|
|
841
|
+
}
|
|
842
|
+
async doFlush(batch) {
|
|
843
|
+
try {
|
|
844
|
+
await this.db.insertBatch(batch);
|
|
845
|
+
await this.db.evictOldRows(this.options.maxRows);
|
|
846
|
+
this.emitter.emit("batch", batch);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
console.error("[ingester] doFlush error:", err);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// -------------------------------------------------------------------------
|
|
852
|
+
// Flush timer management
|
|
853
|
+
// -------------------------------------------------------------------------
|
|
854
|
+
resetFlushTimer() {
|
|
855
|
+
if (this.flushTimer !== null) {
|
|
856
|
+
clearTimeout(this.flushTimer);
|
|
857
|
+
}
|
|
858
|
+
if (!this.stopped) {
|
|
859
|
+
this.flushTimer = setTimeout(() => {
|
|
860
|
+
this.flushTimer = null;
|
|
861
|
+
this.flushBuffer();
|
|
862
|
+
}, this.options.flushIntervalMs);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function matchesFilter(log, filter) {
|
|
867
|
+
if (filter.level !== void 0 && log.level !== filter.level) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
if (filter.service !== void 0 && log.service !== filter.service) {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
if (filter.source !== void 0 && log.source !== filter.source) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
if (filter.search !== void 0 && filter.search !== "") {
|
|
877
|
+
const needle = filter.search.toLowerCase();
|
|
878
|
+
if (!log.message?.toLowerCase().includes(needle)) {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
function serializeLog(log) {
|
|
885
|
+
let rawParsed;
|
|
886
|
+
try {
|
|
887
|
+
rawParsed = JSON.parse(log._raw);
|
|
888
|
+
} catch {
|
|
889
|
+
rawParsed = {};
|
|
890
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
_id: String(log._id),
|
|
893
|
+
_ingested: log._ingested.toISOString(),
|
|
894
|
+
_raw: rawParsed,
|
|
895
|
+
timestamp: log.timestamp?.toISOString() ?? null,
|
|
896
|
+
level: log.level,
|
|
897
|
+
message: log.message,
|
|
898
|
+
service: log.service,
|
|
899
|
+
trace_id: log.trace_id,
|
|
900
|
+
host: log.host,
|
|
901
|
+
duration_ms: log.duration_ms,
|
|
902
|
+
source: log.source
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function serializeStats(stats) {
|
|
906
|
+
return {
|
|
907
|
+
total: stats.total,
|
|
908
|
+
byLevel: stats.byLevel,
|
|
909
|
+
errorRate: stats.errorRate,
|
|
910
|
+
timeRange: {
|
|
911
|
+
min: stats.timeRange.min?.toISOString() ?? null,
|
|
912
|
+
max: stats.timeRange.max?.toISOString() ?? null
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
class WSHandler {
|
|
917
|
+
clients = /* @__PURE__ */ new Map();
|
|
918
|
+
db = null;
|
|
919
|
+
/** Number of currently connected clients */
|
|
920
|
+
get clientCount() {
|
|
921
|
+
return this.clients.size;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Set the LogDatabase reference for stats retrieval during broadcast.
|
|
925
|
+
*/
|
|
926
|
+
setDatabase(db) {
|
|
927
|
+
this.db = db;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Subscribe to an Ingester's "batch" event.
|
|
931
|
+
* When a batch is ingested, broadcasts matching logs to clients.
|
|
932
|
+
*/
|
|
933
|
+
subscribe(ingester) {
|
|
934
|
+
ingester.on("batch", async (logs) => {
|
|
935
|
+
let stats = null;
|
|
936
|
+
if (this.db) {
|
|
937
|
+
try {
|
|
938
|
+
stats = await this.db.getStats();
|
|
939
|
+
} catch {
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (stats) {
|
|
943
|
+
this.broadcast([...logs], stats);
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Handle a new WebSocket connection.
|
|
949
|
+
* Registers the client with an empty (match-all) filter.
|
|
950
|
+
*/
|
|
951
|
+
handleConnection(ws) {
|
|
952
|
+
this.clients.set(ws, {});
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Handle a message from a WebSocket client.
|
|
956
|
+
* Expects JSON messages of type WSClientMessage.
|
|
957
|
+
* Invalid messages are silently ignored.
|
|
958
|
+
*/
|
|
959
|
+
handleMessage(ws, message) {
|
|
960
|
+
if (!this.clients.has(ws)) return;
|
|
961
|
+
let parsed;
|
|
962
|
+
try {
|
|
963
|
+
parsed = JSON.parse(message);
|
|
964
|
+
} catch {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const msg = parsed;
|
|
971
|
+
if (msg.type !== "filter") {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (typeof msg.filter !== "object" || msg.filter === null || Array.isArray(msg.filter)) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const filterObj = msg.filter;
|
|
978
|
+
const filter = {};
|
|
979
|
+
if (typeof filterObj.level === "string") {
|
|
980
|
+
filter.level = filterObj.level;
|
|
981
|
+
}
|
|
982
|
+
if (typeof filterObj.service === "string") {
|
|
983
|
+
filter.service = filterObj.service;
|
|
984
|
+
}
|
|
985
|
+
if (typeof filterObj.source === "string") {
|
|
986
|
+
filter.source = filterObj.source;
|
|
987
|
+
}
|
|
988
|
+
if (typeof filterObj.search === "string") {
|
|
989
|
+
filter.search = filterObj.search;
|
|
990
|
+
}
|
|
991
|
+
this.clients.set(ws, filter);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Handle WebSocket connection close.
|
|
995
|
+
* Removes the client from the registry.
|
|
996
|
+
*/
|
|
997
|
+
handleClose(ws) {
|
|
998
|
+
this.clients.delete(ws);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Get the current filter for a client.
|
|
1002
|
+
* Returns undefined if the client is not registered.
|
|
1003
|
+
*/
|
|
1004
|
+
getClientFilter(ws) {
|
|
1005
|
+
return this.clients.get(ws);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Broadcast new logs and stats to all connected clients.
|
|
1009
|
+
* Logs are filtered per-client based on their active filter.
|
|
1010
|
+
* Stats are always sent to all clients.
|
|
1011
|
+
* If no logs match a client's filter, only stats are sent.
|
|
1012
|
+
*/
|
|
1013
|
+
broadcast(logs, stats) {
|
|
1014
|
+
const serializedStats = JSON.stringify({
|
|
1015
|
+
type: "stats",
|
|
1016
|
+
data: serializeStats(stats)
|
|
1017
|
+
});
|
|
1018
|
+
for (const [ws, filter] of this.clients) {
|
|
1019
|
+
if (ws.readyState !== 1) continue;
|
|
1020
|
+
const matched = logs.filter((log) => matchesFilter(log, filter));
|
|
1021
|
+
if (matched.length > 0) {
|
|
1022
|
+
const logsMessage = JSON.stringify({
|
|
1023
|
+
type: "logs",
|
|
1024
|
+
data: matched.map(serializeLog)
|
|
1025
|
+
});
|
|
1026
|
+
ws.send(logsMessage);
|
|
1027
|
+
}
|
|
1028
|
+
ws.send(serializedStats);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
function parseCLIArgs(args) {
|
|
1033
|
+
const { values } = parseArgs({
|
|
1034
|
+
args,
|
|
1035
|
+
options: {
|
|
1036
|
+
port: { type: "string", short: "p" },
|
|
1037
|
+
"max-rows": { type: "string", short: "m" },
|
|
1038
|
+
"batch-size": { type: "string" },
|
|
1039
|
+
db: { type: "string" },
|
|
1040
|
+
"no-ui": { type: "boolean" },
|
|
1041
|
+
help: { type: "boolean", short: "h" }
|
|
1042
|
+
},
|
|
1043
|
+
strict: true
|
|
1044
|
+
});
|
|
1045
|
+
const result = {
|
|
1046
|
+
port: values.port !== void 0 ? parseInt(values.port, 10) : 8080,
|
|
1047
|
+
maxRows: values["max-rows"] !== void 0 ? parseInt(values["max-rows"], 10) : 1e5,
|
|
1048
|
+
batchSize: values["batch-size"] !== void 0 ? parseInt(values["batch-size"], 10) : 5e3,
|
|
1049
|
+
db: values.db ?? ":memory:",
|
|
1050
|
+
noUi: values["no-ui"] ?? false
|
|
1051
|
+
};
|
|
1052
|
+
if (values.help) {
|
|
1053
|
+
result.help = true;
|
|
1054
|
+
}
|
|
1055
|
+
return result;
|
|
1056
|
+
}
|
|
1057
|
+
const USAGE = `
|
|
1058
|
+
Usage: <command> | lduck [options]
|
|
1059
|
+
|
|
1060
|
+
Options:
|
|
1061
|
+
-p, --port <port> Server port (default: 8080)
|
|
1062
|
+
-m, --max-rows <n> Maximum rows to keep (default: 100000)
|
|
1063
|
+
--batch-size <n> Batch INSERT size (default: 5000)
|
|
1064
|
+
--db <path> DuckDB persistence path (default: :memory:)
|
|
1065
|
+
--no-ui Disable Web UI, API server only
|
|
1066
|
+
-h, --help Show this help message
|
|
1067
|
+
|
|
1068
|
+
Examples:
|
|
1069
|
+
kubectl logs -f deploy/api | lduck --port 8080
|
|
1070
|
+
cat app.log | lduck --db ./logs.duckdb
|
|
1071
|
+
docker logs -f myapp | lduck --no-ui -p 9090
|
|
1072
|
+
`.trimStart();
|
|
1073
|
+
async function main() {
|
|
1074
|
+
const opts = parseCLIArgs(process.argv.slice(2));
|
|
1075
|
+
if (opts.help) {
|
|
1076
|
+
process.stdout.write(USAGE);
|
|
1077
|
+
process.exit(0);
|
|
1078
|
+
}
|
|
1079
|
+
const db = new LogDatabase();
|
|
1080
|
+
await db.initialize(opts.db);
|
|
1081
|
+
const ingester = new Ingester(db, {
|
|
1082
|
+
batchSize: opts.batchSize,
|
|
1083
|
+
flushIntervalMs: 500,
|
|
1084
|
+
maxRows: opts.maxRows,
|
|
1085
|
+
defaultSource: "default"
|
|
1086
|
+
});
|
|
1087
|
+
const wsHandler = new WSHandler();
|
|
1088
|
+
wsHandler.setDatabase(db);
|
|
1089
|
+
wsHandler.subscribe(ingester);
|
|
1090
|
+
const apiApp = createApiApp(db, ingester);
|
|
1091
|
+
const wsInit = { app: null };
|
|
1092
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket(wsInit);
|
|
1093
|
+
const fullApp = createApp("/static/client.js", (app) => {
|
|
1094
|
+
app.route("/", apiApp);
|
|
1095
|
+
app.get(
|
|
1096
|
+
"/api/ws/tail",
|
|
1097
|
+
upgradeWebSocket(() => ({
|
|
1098
|
+
onOpen(_evt, ws) {
|
|
1099
|
+
wsHandler.handleConnection(ws);
|
|
1100
|
+
},
|
|
1101
|
+
onMessage(evt, ws) {
|
|
1102
|
+
wsHandler.handleMessage(ws, evt.data.toString());
|
|
1103
|
+
},
|
|
1104
|
+
onClose(_evt, ws) {
|
|
1105
|
+
wsHandler.handleClose(ws);
|
|
1106
|
+
}
|
|
1107
|
+
}))
|
|
1108
|
+
);
|
|
1109
|
+
if (!opts.noUi) {
|
|
1110
|
+
app.use("/static/*", serveStatic({ root: import.meta.dirname }));
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
wsInit.app = fullApp;
|
|
1114
|
+
const server = serve(
|
|
1115
|
+
{ fetch: fullApp.fetch, port: opts.port, hostname: "127.0.0.1" },
|
|
1116
|
+
(info) => {
|
|
1117
|
+
console.log(`lduck server listening on http://localhost:${info.port}`);
|
|
1118
|
+
}
|
|
1119
|
+
);
|
|
1120
|
+
injectWebSocket(server);
|
|
1121
|
+
ingester.start(process.stdin);
|
|
1122
|
+
}
|
|
1123
|
+
if (!process.env.VITEST) {
|
|
1124
|
+
main().catch((err) => {
|
|
1125
|
+
console.error("Failed to start lduck:", err);
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
export {
|
|
1130
|
+
parseCLIArgs
|
|
1131
|
+
};
|