dsh-local-telemetry 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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * dsh-local-telemetry — SQLite sink(计划 §8.2 / Phase 5,可选后端)。
3
+ *
4
+ * 零 npm 依赖:使用 Node ≥22.5 内置 node:sqlite(宿主已有 SQLite 能力路径)。
5
+ * Node 18/20 下 sqliteAvailable() 返回不可用及原因,CLI/录制器优雅降级,
6
+ * 绝不因此阻塞业务(fail-open)。
7
+ *
8
+ * 与 JSONL sink 共享同一事件 schema 与计数器语义;同一事件集上两者必须
9
+ * 产出一致聚合结果(Phase 5 验收)。行存储完整 JSON(data 列)+ 查询列,
10
+ * 全部使用参数化查询(§6.3)。大小上限的等价策略:超出后按最旧日期分批
11
+ * 清理(SQLite 无文件轮转概念,行为写入文档)。
12
+ */
13
+
14
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { MAX_EVENT_BYTES, serializeEvent, validateEvent } from "./schema.mjs";
18
+ import { utcDateOf } from "./sink-jsonl.mjs";
19
+
20
+ const DB_FILE = "telemetry.sqlite3";
21
+ const META_FILE = "meta.json";
22
+
23
+ let sqliteModuleCache = undefined;
24
+
25
+ /** 探测 node:sqlite 可用性(结果缓存)。 */
26
+ export async function sqliteAvailable() {
27
+ if (sqliteModuleCache !== undefined) return sqliteModuleCache;
28
+ try {
29
+ const mod = await import("node:sqlite");
30
+ sqliteModuleCache = { available: true, module: mod, reason: null };
31
+ } catch (error) {
32
+ sqliteModuleCache = { available: false, module: null, reason: `node:sqlite unavailable (${error.message})` };
33
+ }
34
+ return sqliteModuleCache;
35
+ }
36
+
37
+ export async function isSqliteSupported() {
38
+ return (await sqliteAvailable()).available;
39
+ }
40
+
41
+ const COLUMNS = /** @type {const} */ ([
42
+ "ts",
43
+ "date",
44
+ "event",
45
+ "event_id",
46
+ "trace_id",
47
+ "span_id",
48
+ "parent_id",
49
+ "request_id",
50
+ "profile",
51
+ "provider",
52
+ "model_name",
53
+ "tool_name",
54
+ "plugin_name",
55
+ "status",
56
+ "duration_ms",
57
+ "input_tokens",
58
+ "output_tokens",
59
+ "cached_input_tokens",
60
+ ]);
61
+
62
+ function extractColumns(event) {
63
+ return {
64
+ ts: event.timestamp,
65
+ date: utcDateOf(event.timestamp),
66
+ event: event.event,
67
+ event_id: event.event_id,
68
+ trace_id: event.trace_id,
69
+ span_id: event.span_id,
70
+ parent_id: event.parent_id ?? null,
71
+ request_id: event.request_id ?? null,
72
+ profile: event.session?.profile ?? null,
73
+ provider: event.model?.provider ?? null,
74
+ model_name: event.model?.name ?? null,
75
+ tool_name: event.tool?.name ?? null,
76
+ plugin_name: event.plugin?.name ?? null,
77
+ status: event.result?.status ?? (event.event === "model.failed" ? "failed" : event.event === "request.cancelled" ? "cancelled" : null),
78
+ duration_ms: Number.isInteger(event.duration_ms) ? event.duration_ms : null,
79
+ input_tokens: event.usage?.input_tokens ?? null,
80
+ output_tokens: event.usage?.output_tokens ?? null,
81
+ cached_input_tokens: event.usage?.cached_input_tokens ?? null,
82
+ };
83
+ }
84
+
85
+ export function createSqliteSink({
86
+ dir,
87
+ maxFileBytes = 100 * 1024 * 1024,
88
+ batchCount = 100,
89
+ flushIntervalMs = 1000,
90
+ maxQueue = 1000,
91
+ retentionDays = 0,
92
+ now = () => Date.now(),
93
+ } = {}) {
94
+ if (!dir || typeof dir !== "string") throw new TypeError("createSqliteSink requires dir");
95
+ const sink = {
96
+ dir,
97
+ maxFileBytes,
98
+ batchCount: Math.max(1, Math.round(batchCount)),
99
+ flushIntervalMs: Math.max(50, Math.round(flushIntervalMs)),
100
+ maxQueue: Math.max(1, Math.round(maxQueue)),
101
+ retentionDays,
102
+ now,
103
+ queue: [],
104
+ counters: { written: 0, dropped_queue: 0, dropped_oversize: 0, dropped_invalid: 0, dropped_write: 0, sampled_out: 0 },
105
+ lastWriteError: null,
106
+ _db: null,
107
+ _Database: null,
108
+ _timer: null,
109
+ _closed: false,
110
+ _started: false,
111
+ _metaDirty: false,
112
+ _chain: Promise.resolve(),
113
+ };
114
+
115
+ async function start() {
116
+ if (sink._started) return;
117
+ sink._started = true;
118
+ const availability = await sqliteAvailable();
119
+ if (!availability.available) {
120
+ sink.lastWriteError = availability.reason;
121
+ return;
122
+ }
123
+ try {
124
+ await mkdir(dir, { recursive: true });
125
+ sink._Database = availability.module.DatabaseSync;
126
+ sink._db = new sink._Database(join(dir, DB_FILE));
127
+ sink._db.exec("PRAGMA journal_mode = WAL;");
128
+ sink._db.exec("PRAGMA synchronous = NORMAL;");
129
+ sink._db.exec(`
130
+ CREATE TABLE IF NOT EXISTS events (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
132
+ ts TEXT NOT NULL,
133
+ date TEXT NOT NULL,
134
+ event TEXT NOT NULL,
135
+ event_id TEXT NOT NULL,
136
+ trace_id TEXT NOT NULL,
137
+ span_id TEXT NOT NULL,
138
+ parent_id TEXT,
139
+ request_id TEXT,
140
+ profile TEXT,
141
+ provider TEXT,
142
+ model_name TEXT,
143
+ tool_name TEXT,
144
+ plugin_name TEXT,
145
+ status TEXT,
146
+ duration_ms INTEGER,
147
+ input_tokens INTEGER,
148
+ output_tokens INTEGER,
149
+ cached_input_tokens INTEGER,
150
+ data TEXT NOT NULL
151
+ );
152
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
153
+ CREATE INDEX IF NOT EXISTS idx_events_trace ON events(trace_id);
154
+ CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
155
+ CREATE INDEX IF NOT EXISTS idx_events_event ON events(event);
156
+ `);
157
+ await loadMeta();
158
+ sink._timer = setInterval(() => {
159
+ void flush();
160
+ }, sink.flushIntervalMs);
161
+ if (typeof sink._timer.unref === "function") sink._timer.unref();
162
+ if (sink.retentionDays > 0) {
163
+ void purge({ olderThanMs: sink.retentionDays * 86_400_000 }).catch(() => {});
164
+ }
165
+ } catch (error) {
166
+ sink.lastWriteError = String(error?.message ?? error);
167
+ try {
168
+ sink._db?.close();
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ sink._db = null;
173
+ }
174
+ }
175
+
176
+ function write(event) {
177
+ if (sink._closed) return { ok: false, reason: "sink_closed" };
178
+ if (!sink._db) return { ok: false, reason: "sqlite_unavailable", detail: sink.lastWriteError };
179
+ const check = validateEvent(event);
180
+ if (!check.ok) {
181
+ sink.counters.dropped_invalid += 1;
182
+ sink._metaDirty = true;
183
+ return { ok: false, reason: "invalid_event", errors: check.errors };
184
+ }
185
+ let payload;
186
+ try {
187
+ payload = serializeEvent(event);
188
+ } catch {
189
+ sink.counters.dropped_invalid += 1;
190
+ sink._metaDirty = true;
191
+ return { ok: false, reason: "invalid_event" };
192
+ }
193
+ if (Buffer.byteLength(payload, "utf8") > MAX_EVENT_BYTES) {
194
+ sink.counters.dropped_oversize += 1;
195
+ sink._metaDirty = true;
196
+ return { ok: false, reason: "oversize" };
197
+ }
198
+ if (sink.queue.length >= sink.maxQueue) {
199
+ sink.counters.dropped_queue += 1;
200
+ sink._metaDirty = true;
201
+ return { ok: false, reason: "queue_full" };
202
+ }
203
+ sink.queue.push({ payload, columns: extractColumns(event) });
204
+ if (sink.queue.length >= sink.batchCount) void flush();
205
+ return { ok: true };
206
+ }
207
+
208
+ function countDropped(kind) {
209
+ if (kind === undefined || kind === null) return;
210
+ const key = kind === "sampled_out" ? "sampled_out" : `dropped_${kind}`;
211
+ if (key in sink.counters) {
212
+ sink.counters[key] += 1;
213
+ sink._metaDirty = true;
214
+ }
215
+ }
216
+
217
+ async function flush() {
218
+ if (sink._closed) return drainMeta();
219
+ const batch = sink.queue.splice(0, sink.queue.length);
220
+ if (batch.length > 0) {
221
+ sink._chain = sink._chain.then(() => appendBatch(batch)).catch(() => {});
222
+ await sink._chain;
223
+ }
224
+ await enforceSizeCap();
225
+ return drainMeta();
226
+ }
227
+
228
+ function appendBatch(batch) {
229
+ if (!sink._db) {
230
+ sink.counters.dropped_write += batch.length;
231
+ sink._metaDirty = true;
232
+ return;
233
+ }
234
+ try {
235
+ const insert = sink._db.prepare(
236
+ `INSERT INTO events (${COLUMNS.join(",")}, data) VALUES (${COLUMNS.map(() => "?").join(",")}, ?)`
237
+ );
238
+ sink._db.exec("BEGIN");
239
+ try {
240
+ for (const item of batch) {
241
+ const c = item.columns;
242
+ insert.run(
243
+ c.ts, c.date, c.event, c.event_id, c.trace_id, c.span_id, c.parent_id, c.request_id,
244
+ c.profile, c.provider, c.model_name, c.tool_name, c.plugin_name, c.status,
245
+ c.duration_ms, c.input_tokens, c.output_tokens, c.cached_input_tokens, item.payload
246
+ );
247
+ }
248
+ sink._db.exec("COMMIT");
249
+ sink.counters.written += batch.length;
250
+ } catch (error) {
251
+ try {
252
+ sink._db.exec("ROLLBACK");
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ throw error;
257
+ }
258
+ } catch (error) {
259
+ sink.counters.dropped_write += batch.length;
260
+ sink._metaDirty = true;
261
+ sink.lastWriteError = String(error?.message ?? error);
262
+ }
263
+ }
264
+
265
+ /** SQLite 的「轮转」等价物:超出大小上限时按最旧日期清理。 */
266
+ async function enforceSizeCap() {
267
+ if (!sink._db) return;
268
+ try {
269
+ const info = await stat(join(dir, DB_FILE));
270
+ if (info.size <= sink.maxFileBytes) return;
271
+ for (let i = 0; i < 200; i += 1) {
272
+ const row = sink._db.prepare("SELECT MIN(date) AS d, COUNT(*) AS n FROM events").get();
273
+ if (!row || row.d === null || row.n === 0) break;
274
+ sink._db.prepare("DELETE FROM events WHERE date = ?").run(row.d);
275
+ const after = await stat(join(dir, DB_FILE));
276
+ if (after.size <= sink.maxFileBytes * 0.9) break;
277
+ }
278
+ } catch {
279
+ /* 大小治理失败不影响主流程 */
280
+ }
281
+ }
282
+
283
+ async function loadMeta() {
284
+ try {
285
+ if (!existsSync(join(dir, META_FILE))) return;
286
+ const meta = JSON.parse(await readFile(join(dir, META_FILE), "utf8"));
287
+ if (meta && typeof meta.counters === "object") {
288
+ for (const [key, value] of Object.entries(meta.counters)) {
289
+ if (key in sink.counters && Number.isInteger(value) && value >= 0) sink.counters[key] = value;
290
+ }
291
+ }
292
+ } catch {
293
+ /* 可接受 */
294
+ }
295
+ }
296
+
297
+ async function drainMeta() {
298
+ if (!sink._metaDirty) return;
299
+ try {
300
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
301
+ await writeFile(
302
+ join(dir, META_FILE),
303
+ JSON.stringify({ version: 1, updated_at: new Date(sink.now()).toISOString(), counters: { ...sink.counters } }, null, 2),
304
+ "utf8"
305
+ );
306
+ sink._metaDirty = false;
307
+ } catch {
308
+ /* ignore */
309
+ }
310
+ }
311
+
312
+ async function status() {
313
+ let bytes = 0;
314
+ let rows = 0;
315
+ try {
316
+ bytes = (await stat(join(dir, DB_FILE))).size;
317
+ } catch {
318
+ bytes = 0;
319
+ }
320
+ if (sink._db) {
321
+ try {
322
+ rows = sink._db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
323
+ } catch {
324
+ rows = 0;
325
+ }
326
+ }
327
+ return {
328
+ store: "sqlite",
329
+ dir,
330
+ files: existsSync(join(dir, DB_FILE)) ? [{ name: DB_FILE, bytes }] : [],
331
+ total_bytes: bytes,
332
+ rows,
333
+ counters: { ...sink.counters },
334
+ last_write_error: sink.lastWriteError,
335
+ };
336
+ }
337
+
338
+ async function purge({ olderThanMs = null, beforeMs = null } = {}) {
339
+ const cutoff = beforeMs ?? (olderThanMs !== null ? sink.now() - olderThanMs : null);
340
+ if (cutoff === null) throw new TypeError("purge requires olderThanMs or beforeMs");
341
+ const cutoffDate = utcDateOf(new Date(cutoff).toISOString());
342
+ if (!sink._db) return { removed_files: 0, removed_bytes: 0, removed_rows: 0 };
343
+ try {
344
+ const count = sink._db.prepare("SELECT COUNT(*) AS n FROM events WHERE date < ?").get(cutoffDate).n;
345
+ sink._db.prepare("DELETE FROM events WHERE date < ?").run(cutoffDate);
346
+ sink._metaDirty = true;
347
+ await drainMeta();
348
+ return { removed_files: 0, removed_bytes: 0, removed_rows: count };
349
+ } catch (error) {
350
+ sink.lastWriteError = String(error?.message ?? error);
351
+ return { removed_files: 0, removed_bytes: 0, removed_rows: 0 };
352
+ }
353
+ }
354
+
355
+ async function close() {
356
+ if (sink._closed) return;
357
+ if (sink._timer) {
358
+ clearInterval(sink._timer);
359
+ sink._timer = null;
360
+ }
361
+ try {
362
+ await flush();
363
+ } catch {
364
+ /* ignore */
365
+ }
366
+ sink._closed = true;
367
+ try {
368
+ sink._db?.close();
369
+ } catch {
370
+ /* ignore */
371
+ }
372
+ sink._db = null;
373
+ sink._metaDirty = true;
374
+ try {
375
+ await drainMeta();
376
+ } catch {
377
+ /* ignore */
378
+ }
379
+ }
380
+
381
+ return { write, countDropped, flush, close, status, purge, start, get counters() { return sink.counters; } };
382
+ }