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.
- package/CHANGELOG.md +82 -0
- package/DSH-TELEMETRY-/345/274/200/345/217/221/350/256/241/345/210/222.md +954 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +42 -0
- package/README.md +156 -0
- package/bin/telemetry.mjs +295 -0
- package/cordis.patch.yml +11 -0
- package/docs/configuration.md +148 -0
- package/docs/schema.md +165 -0
- package/examples/prices.json +16 -0
- package/examples/telemetry.json +17 -0
- package/package.json +62 -0
- package/plugin/index.js +82 -0
- package/skills/telemetry-runbook/SKILL.md +88 -0
- package/src/adapter.mjs +79 -0
- package/src/aggregate.mjs +677 -0
- package/src/config.mjs +199 -0
- package/src/cost.mjs +118 -0
- package/src/index.mjs +20 -0
- package/src/privacy.mjs +208 -0
- package/src/recorder.mjs +215 -0
- package/src/report.mjs +220 -0
- package/src/sampling.mjs +50 -0
- package/src/schema.mjs +246 -0
- package/src/server.mjs +163 -0
- package/src/sink-jsonl.mjs +368 -0
- package/src/sink-sqlite.mjs +382 -0
- package/src/store.mjs +346 -0
- package/web/app.js +337 -0
- package/web/index.html +87 -0
- package/web/style.css +158 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — JSONL sink(计划 §6.3 / §7 / §8.1)。
|
|
3
|
+
*
|
|
4
|
+
* 行为契约:
|
|
5
|
+
* - 追加写,一行一个完整事件;写入串行化,不产生半行 JSON;
|
|
6
|
+
* - fail-open:任何写盘失败只计数并本地告警(节流),绝不抛给调用方;
|
|
7
|
+
* - 队列上限(默认 1000):满时丢弃新到事件并计数(明确的丢弃策略);
|
|
8
|
+
* - 批量落盘:batch_size 条或 flush_interval_ms 触发;
|
|
9
|
+
* - 单事件 > 64KB 丢弃计数;坏 JSON / 校验失败丢弃计数;
|
|
10
|
+
* - 按事件 UTC 日期分文件 `<date>.jsonl`,超过 max_file_mb 轮转为
|
|
11
|
+
* `<date>.part-NNN.jsonl`;
|
|
12
|
+
* - 计数器持久化到 `<dir>/meta.json`;保留期清理删除过期日期文件。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
appendFile,
|
|
17
|
+
mkdir,
|
|
18
|
+
readdir,
|
|
19
|
+
readFile,
|
|
20
|
+
stat,
|
|
21
|
+
unlink,
|
|
22
|
+
writeFile,
|
|
23
|
+
} from "node:fs/promises";
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { MAX_EVENT_BYTES, serializeEvent, validateEvent } from "./schema.mjs";
|
|
27
|
+
|
|
28
|
+
const META_FILE = "meta.json";
|
|
29
|
+
|
|
30
|
+
export class JsonlSink {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {string} opts.dir 数据目录(逐日 JSONL 所在)
|
|
34
|
+
* @param {number} [opts.maxFileBytes] 单文件上限(默认 100MB)
|
|
35
|
+
* @param {number} [opts.batchCount] 批量大小(默认 100)
|
|
36
|
+
* @param {number} [opts.flushIntervalMs] 定时 flush(默认 1000)
|
|
37
|
+
* @param {number} [opts.maxQueue] 队列上限(默认 1000)
|
|
38
|
+
* @param {number} [opts.retentionDays] 启动时后台应用的保留期(0/undefined = 不自动清理)
|
|
39
|
+
* @param {() => number} [opts.now] 可注入时钟(测试)
|
|
40
|
+
*/
|
|
41
|
+
constructor({
|
|
42
|
+
dir,
|
|
43
|
+
maxFileBytes = 100 * 1024 * 1024,
|
|
44
|
+
batchCount = 100,
|
|
45
|
+
flushIntervalMs = 1000,
|
|
46
|
+
maxQueue = 1000,
|
|
47
|
+
retentionDays = 0,
|
|
48
|
+
now = () => Date.now(),
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (!dir || typeof dir !== "string") throw new TypeError("JsonlSink requires dir");
|
|
51
|
+
this.dir = dir;
|
|
52
|
+
this.maxFileBytes = Math.max(1024, maxFileBytes);
|
|
53
|
+
this.batchCount = Math.max(1, Math.round(batchCount));
|
|
54
|
+
this.flushIntervalMs = Math.max(50, Math.round(flushIntervalMs));
|
|
55
|
+
this.maxQueue = Math.max(1, Math.round(maxQueue));
|
|
56
|
+
this.retentionDays = retentionDays;
|
|
57
|
+
this.now = now;
|
|
58
|
+
|
|
59
|
+
this.queue = [];
|
|
60
|
+
this.counters = {
|
|
61
|
+
written: 0,
|
|
62
|
+
dropped_queue: 0,
|
|
63
|
+
dropped_oversize: 0,
|
|
64
|
+
dropped_invalid: 0,
|
|
65
|
+
dropped_write: 0,
|
|
66
|
+
sampled_out: 0,
|
|
67
|
+
};
|
|
68
|
+
this.lastWriteError = null;
|
|
69
|
+
this._warnedWriteError = false;
|
|
70
|
+
this._metaDirty = false;
|
|
71
|
+
this._closed = false;
|
|
72
|
+
this._chain = Promise.resolve(); // 串行写链,保证无半行 JSON
|
|
73
|
+
this._timer = null;
|
|
74
|
+
this._started = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 惰性启动:建目录、恢复计数器、起定时器、后台清理保留期。 */
|
|
78
|
+
async start() {
|
|
79
|
+
if (this._started) return;
|
|
80
|
+
this._started = true;
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(this.dir, { recursive: true });
|
|
83
|
+
} catch {
|
|
84
|
+
/* fail-open:写事件时再试 */
|
|
85
|
+
}
|
|
86
|
+
await this._loadMeta();
|
|
87
|
+
this._timer = setInterval(() => {
|
|
88
|
+
void this.flush();
|
|
89
|
+
}, this.flushIntervalMs);
|
|
90
|
+
if (typeof this._timer.unref === "function") this._timer.unref();
|
|
91
|
+
if (this.retentionDays > 0) {
|
|
92
|
+
void this.purge({ olderThanMs: this.retentionDays * 86_400_000 }).catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 入队一条已构造好的事件;任何失败只计数(fail-open)。 */
|
|
97
|
+
write(event) {
|
|
98
|
+
if (this._closed) return { ok: false, reason: "sink_closed" };
|
|
99
|
+
const check = validateEvent(event);
|
|
100
|
+
if (!check.ok) {
|
|
101
|
+
this.counters.dropped_invalid += 1;
|
|
102
|
+
this._metaDirty = true;
|
|
103
|
+
return { ok: false, reason: "invalid_event", errors: check.errors };
|
|
104
|
+
}
|
|
105
|
+
let line;
|
|
106
|
+
try {
|
|
107
|
+
line = serializeEvent(event);
|
|
108
|
+
} catch {
|
|
109
|
+
this.counters.dropped_invalid += 1;
|
|
110
|
+
this._metaDirty = true;
|
|
111
|
+
return { ok: false, reason: "invalid_event" };
|
|
112
|
+
}
|
|
113
|
+
if (Buffer.byteLength(line, "utf8") > MAX_EVENT_BYTES) {
|
|
114
|
+
this.counters.dropped_oversize += 1;
|
|
115
|
+
this._metaDirty = true;
|
|
116
|
+
return { ok: false, reason: "oversize" };
|
|
117
|
+
}
|
|
118
|
+
if (this.queue.length >= this.maxQueue) {
|
|
119
|
+
this.counters.dropped_queue += 1;
|
|
120
|
+
this._metaDirty = true;
|
|
121
|
+
return { ok: false, reason: "queue_full" };
|
|
122
|
+
}
|
|
123
|
+
this.queue.push({ line, date: utcDateOf(event.timestamp), bytes: Buffer.byteLength(line, "utf8") + 1 });
|
|
124
|
+
if (this.queue.length >= this.batchCount) void this.flush();
|
|
125
|
+
return { ok: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 外部丢弃计数(如采样丢弃),进入同一份持久化账本。 */
|
|
129
|
+
countDropped(kind) {
|
|
130
|
+
if (kind === undefined || kind === null) return;
|
|
131
|
+
const key = kind === "sampled_out" ? "sampled_out" : `dropped_${kind}`;
|
|
132
|
+
if (key in this.counters) {
|
|
133
|
+
this.counters[key] += 1;
|
|
134
|
+
this._metaDirty = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 把当前队列快照落盘;串行链上执行,失败 fail-open。 */
|
|
139
|
+
async flush() {
|
|
140
|
+
await this._chain; // 先等在途写完(write 触发的批量 flush 可能仍在执行)
|
|
141
|
+
if (this._closed) return this._drainMeta();
|
|
142
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
143
|
+
if (batch.length > 0) {
|
|
144
|
+
this._chain = this._chain
|
|
145
|
+
.then(() => this._appendBatch(batch))
|
|
146
|
+
.catch(() => {
|
|
147
|
+
// 防御:链上任何未预期异常不得外泄
|
|
148
|
+
this.counters.dropped_write += batch.length;
|
|
149
|
+
this._metaDirty = true;
|
|
150
|
+
});
|
|
151
|
+
await this._chain;
|
|
152
|
+
}
|
|
153
|
+
return this._drainMeta();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async _appendBatch(batch) {
|
|
157
|
+
try {
|
|
158
|
+
if (!existsSync(this.dir)) await mkdir(this.dir, { recursive: true });
|
|
159
|
+
} catch {
|
|
160
|
+
/* 继续尝试写入 */
|
|
161
|
+
}
|
|
162
|
+
const byDate = new Map();
|
|
163
|
+
for (const item of batch) {
|
|
164
|
+
if (!byDate.has(item.date)) byDate.set(item.date, []);
|
|
165
|
+
byDate.get(item.date).push(item);
|
|
166
|
+
}
|
|
167
|
+
for (const [date, items] of byDate) {
|
|
168
|
+
await this._appendDateBatch(date, items);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 按日期分块写入:逐事件检查容量,溢出即轮转到下一个 part 文件。
|
|
174
|
+
* 连续落在同一目标的行合并为单次 append,避免半行 JSON 也减少系统调用。
|
|
175
|
+
*/
|
|
176
|
+
async _appendDateBatch(date, items) {
|
|
177
|
+
const sizes = new Map();
|
|
178
|
+
try {
|
|
179
|
+
for (const name of await readdir(this.dir)) {
|
|
180
|
+
if (!name.startsWith(`${date}.`) || !name.endsWith(".jsonl")) continue;
|
|
181
|
+
try {
|
|
182
|
+
sizes.set(name, (await stat(join(this.dir, name))).size);
|
|
183
|
+
} catch {
|
|
184
|
+
/* 忽略瞬时消失的文件 */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* 目录尚不存在:写入时会再试 */
|
|
189
|
+
}
|
|
190
|
+
let currentName = null;
|
|
191
|
+
let currentSize = 0;
|
|
192
|
+
let buffer = [];
|
|
193
|
+
let bufferBytes = 0;
|
|
194
|
+
|
|
195
|
+
const flushBuffer = async () => {
|
|
196
|
+
if (buffer.length === 0 || currentName === null) {
|
|
197
|
+
buffer = [];
|
|
198
|
+
bufferBytes = 0;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const payload = buffer.map((item) => item.line).join("\n") + "\n";
|
|
202
|
+
try {
|
|
203
|
+
await appendFile(join(this.dir, currentName), payload, "utf8");
|
|
204
|
+
this.counters.written += buffer.length;
|
|
205
|
+
currentSize += bufferBytes;
|
|
206
|
+
sizes.set(currentName, currentSize);
|
|
207
|
+
this._warnedWriteError = false;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.counters.dropped_write += buffer.length;
|
|
210
|
+
this._metaDirty = true;
|
|
211
|
+
this.lastWriteError = String(error?.code ?? error?.message ?? error);
|
|
212
|
+
this._localWarn();
|
|
213
|
+
}
|
|
214
|
+
buffer = [];
|
|
215
|
+
bufferBytes = 0;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
for (const item of items) {
|
|
219
|
+
const needNewTarget =
|
|
220
|
+
currentName === null || (currentSize + bufferBytes + item.bytes > this.maxFileBytes && currentSize + bufferBytes > 0);
|
|
221
|
+
if (needNewTarget) {
|
|
222
|
+
await flushBuffer();
|
|
223
|
+
currentName = this._pickTarget(date, item.bytes, sizes);
|
|
224
|
+
currentSize = sizes.get(currentName) ?? 0;
|
|
225
|
+
}
|
|
226
|
+
buffer.push(item);
|
|
227
|
+
bufferBytes += item.bytes;
|
|
228
|
+
if (currentSize + bufferBytes >= this.maxFileBytes) {
|
|
229
|
+
await flushBuffer(); // 当前文件已到上限,下次写入将轮转
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await flushBuffer();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 选择目标文件名:`<date>.jsonl` 优先,已满则寻找/新建 `<date>.part-NNN.jsonl`。 */
|
|
236
|
+
_pickTarget(date, bytes, sizes) {
|
|
237
|
+
const base = `${date}.jsonl`;
|
|
238
|
+
if (!sizes.has(base) || sizes.get(base) + bytes <= this.maxFileBytes) return base;
|
|
239
|
+
for (let index = 1; index <= 9999; index += 1) {
|
|
240
|
+
const name = `${date}.part-${String(index).padStart(3, "0")}.jsonl`;
|
|
241
|
+
if (!sizes.has(name) || sizes.get(name) + bytes <= this.maxFileBytes) return name;
|
|
242
|
+
}
|
|
243
|
+
return base; // 兜底:交给写失败计数
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_localWarn() {
|
|
247
|
+
if (this._warnedWriteError) return;
|
|
248
|
+
this._warnedWriteError = true;
|
|
249
|
+
// 本地告警:仅 stderr,一次性,不阻塞业务
|
|
250
|
+
console.warn?.(`[dsh-local-telemetry] telemetry write failed (${this.lastWriteError}); events will be dropped until it recovers`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async _loadMeta() {
|
|
254
|
+
try {
|
|
255
|
+
if (!existsSync(join(this.dir, META_FILE))) return;
|
|
256
|
+
const meta = JSON.parse(await readFile(join(this.dir, META_FILE), "utf8"));
|
|
257
|
+
if (meta && typeof meta.counters === "object") {
|
|
258
|
+
for (const [key, value] of Object.entries(meta.counters)) {
|
|
259
|
+
if (key in this.counters && Number.isInteger(value) && value >= 0) this.counters[key] = value;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
/* 计数器丢失可接受 */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async _drainMeta() {
|
|
268
|
+
if (!this._metaDirty) return;
|
|
269
|
+
try {
|
|
270
|
+
if (!existsSync(this.dir)) await mkdir(this.dir, { recursive: true });
|
|
271
|
+
const meta = { version: 1, updated_at: new Date(this.now()).toISOString(), counters: { ...this.counters } };
|
|
272
|
+
await writeFile(join(this.dir, META_FILE), JSON.stringify(meta, null, 2), "utf8");
|
|
273
|
+
this._metaDirty = false;
|
|
274
|
+
} catch {
|
|
275
|
+
/* 计数持久化失败不影响主流程 */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 文件级状态(CLI --status / Web UI)。 */
|
|
280
|
+
async status() {
|
|
281
|
+
let files = [];
|
|
282
|
+
let totalBytes = 0;
|
|
283
|
+
try {
|
|
284
|
+
const names = (await readdir(this.dir)).filter((name) => name.endsWith(".jsonl")).sort();
|
|
285
|
+
for (const name of names) {
|
|
286
|
+
const info = await stat(join(this.dir, name));
|
|
287
|
+
files.push({ name, bytes: info.size, mtime: info.mtimeMs });
|
|
288
|
+
totalBytes += info.size;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
files = [];
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
store: "jsonl",
|
|
295
|
+
dir: this.dir,
|
|
296
|
+
files,
|
|
297
|
+
total_bytes: totalBytes,
|
|
298
|
+
counters: { ...this.counters },
|
|
299
|
+
last_write_error: this.lastWriteError,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 保留期清理。
|
|
305
|
+
* @param {{ olderThanMs?: number, beforeMs?: number }} opts 二选一:
|
|
306
|
+
* olderThanMs(相对现在回溯)或 beforeMs(绝对截止 epoch ms)。
|
|
307
|
+
* @returns {{ removed_files: number, removed_bytes: number }}
|
|
308
|
+
*/
|
|
309
|
+
async purge({ olderThanMs = null, beforeMs = null } = {}) {
|
|
310
|
+
const now = this.now();
|
|
311
|
+
const cutoff = beforeMs ?? (olderThanMs !== null ? now - olderThanMs : null);
|
|
312
|
+
if (cutoff === null) throw new TypeError("purge requires olderThanMs or beforeMs");
|
|
313
|
+
const cutoffDate = utcDateOf(new Date(cutoff).toISOString());
|
|
314
|
+
let removedFiles = 0;
|
|
315
|
+
let removedBytes = 0;
|
|
316
|
+
let names = [];
|
|
317
|
+
try {
|
|
318
|
+
names = await readdir(this.dir);
|
|
319
|
+
} catch {
|
|
320
|
+
return { removed_files: 0, removed_bytes: 0 };
|
|
321
|
+
}
|
|
322
|
+
for (const name of names) {
|
|
323
|
+
const match = name.match(/^(\d{4}-\d{2}-\d{2})(?:\.part-\d+)?\.jsonl$/);
|
|
324
|
+
if (!match) continue;
|
|
325
|
+
if (match[1] >= cutoffDate) continue; // 日期字符串可直接比较
|
|
326
|
+
const path = join(this.dir, name);
|
|
327
|
+
try {
|
|
328
|
+
const info = await stat(path);
|
|
329
|
+
await unlink(path);
|
|
330
|
+
removedFiles += 1;
|
|
331
|
+
removedBytes += info.size;
|
|
332
|
+
} catch {
|
|
333
|
+
/* 单文件失败不中断 */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
this._metaDirty = true;
|
|
337
|
+
await this._drainMeta();
|
|
338
|
+
return { removed_files: removedFiles, removed_bytes: removedBytes };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 关闭:flush 后停表;flush 失败不阻塞退出(fail-open)。 */
|
|
342
|
+
async close() {
|
|
343
|
+
if (this._closed) return;
|
|
344
|
+
if (this._timer) {
|
|
345
|
+
clearInterval(this._timer);
|
|
346
|
+
this._timer = null;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
await this.flush();
|
|
350
|
+
} catch {
|
|
351
|
+
/* ignore */
|
|
352
|
+
}
|
|
353
|
+
this._closed = true;
|
|
354
|
+
this._metaDirty = true;
|
|
355
|
+
try {
|
|
356
|
+
await this._drainMeta();
|
|
357
|
+
} catch {
|
|
358
|
+
/* ignore */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 事件 timestamp(ISO UTC)→ `YYYY-MM-DD`。 */
|
|
364
|
+
export function utcDateOf(isoTimestamp) {
|
|
365
|
+
const value = typeof isoTimestamp === "string" ? isoTimestamp : "";
|
|
366
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) return value.slice(0, 10);
|
|
367
|
+
return new Date().toISOString().slice(0, 10);
|
|
368
|
+
}
|