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
package/src/recorder.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 独立事件记录器(计划 §2 / Phase 1)。
|
|
3
|
+
*
|
|
4
|
+
* 职责链:ID 补全 → 名称哈希(可选)→ metadata 脱敏(仅 safe 模式)→
|
|
5
|
+
* 按 trace 采样 → sink。全部 fail-open:任何异常只计数,不影响业务请求。
|
|
6
|
+
*
|
|
7
|
+
* 刻意不做的事:
|
|
8
|
+
* - 不臆造 duration/token/成本(这些由聚合器从真实事件对推导);
|
|
9
|
+
* - 不采集内容:metadata 仅在 capture_metadata="safe" 时脱敏后附加;
|
|
10
|
+
* - 不感知宿主私有 API(经 adapter 接入)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createCapabilities } from "./adapter.mjs";
|
|
14
|
+
import { resolveConfig, resolveDataPath } from "./config.mjs";
|
|
15
|
+
import { createRedactor, getOrCreateSalt, hashName, defaultPrivacy } from "./privacy.mjs";
|
|
16
|
+
import { createSampler } from "./sampling.mjs";
|
|
17
|
+
import { SCHEMA_VERSION, newId, nowIsoUtc, validateEvent } from "./schema.mjs";
|
|
18
|
+
import { JsonlSink } from "./sink-jsonl.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {object|undefined} [opts.config] 部分配置(与默认合并)
|
|
23
|
+
* @param {object} [opts.sink] 已构造的 sink(缺省按 config.store 创建 JsonlSink)
|
|
24
|
+
* @param {() => number} [opts.now] 可注入时钟
|
|
25
|
+
*/
|
|
26
|
+
export function createRecorder(opts = {}) {
|
|
27
|
+
const resolution = resolveConfig({ file: opts.config ? { ...opts.config } : undefined });
|
|
28
|
+
const config = resolution.config;
|
|
29
|
+
const now = opts.now ?? (() => Date.now());
|
|
30
|
+
|
|
31
|
+
const recorder = {
|
|
32
|
+
config,
|
|
33
|
+
capabilities: createCapabilities(),
|
|
34
|
+
counters: { recorded: 0, sampled_out: 0, invalid: 0, errors: 0 },
|
|
35
|
+
adapter: null,
|
|
36
|
+
sink: opts.sink ?? null,
|
|
37
|
+
_salt: null,
|
|
38
|
+
_redactor: null,
|
|
39
|
+
_sampler: null,
|
|
40
|
+
_started: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function ensureSalt() {
|
|
44
|
+
if (recorder._salt === null) {
|
|
45
|
+
recorder._salt = getOrCreateSalt(resolveDataPath(config.path));
|
|
46
|
+
}
|
|
47
|
+
return recorder._salt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureSampler() {
|
|
51
|
+
if (recorder._sampler === null) {
|
|
52
|
+
recorder._sampler = createSampler({
|
|
53
|
+
sampleRate: config.sample_rate,
|
|
54
|
+
errorsAlwaysSample: config.errors_always_sample,
|
|
55
|
+
slowRequestMs: config.slow_request_ms,
|
|
56
|
+
salt: ensureSalt(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return recorder._sampler;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureRedactor() {
|
|
63
|
+
if (recorder._redactor === null) {
|
|
64
|
+
recorder._redactor = createRedactor({
|
|
65
|
+
customRules: config.redact_rules,
|
|
66
|
+
absolutePaths: "basename",
|
|
67
|
+
salt: ensureSalt(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return recorder._redactor;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function start() {
|
|
74
|
+
if (recorder._started) return;
|
|
75
|
+
recorder._started = true;
|
|
76
|
+
if (!recorder.sink) {
|
|
77
|
+
recorder.sink = new JsonlSink({
|
|
78
|
+
dir: resolveDataPath(config.path),
|
|
79
|
+
maxFileBytes: config.max_file_mb * 1024 * 1024,
|
|
80
|
+
batchCount: config.batch_size,
|
|
81
|
+
flushIntervalMs: config.flush_interval_ms,
|
|
82
|
+
maxQueue: config.max_queue_events,
|
|
83
|
+
retentionDays: config.retention_days,
|
|
84
|
+
now,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (typeof recorder.sink.start === "function") await recorder.sink.start();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 记录一条事件。返回 { ok, reason },绝不抛出。
|
|
92
|
+
* @param {object} event 事件(缺失的 id/timestamp 会被补全)
|
|
93
|
+
* @param {object} [metadata] 可选 metadata(仅 capture_metadata="safe" 时脱敏附加)
|
|
94
|
+
*/
|
|
95
|
+
function record(event, { metadata } = {}) {
|
|
96
|
+
if (!config.enabled) return { ok: false, reason: "disabled" };
|
|
97
|
+
try {
|
|
98
|
+
const prepared = prepare(event, metadata);
|
|
99
|
+
if (!prepared.ok) {
|
|
100
|
+
recorder.counters.invalid += 1;
|
|
101
|
+
return prepared;
|
|
102
|
+
}
|
|
103
|
+
const sampler = ensureSampler();
|
|
104
|
+
const decision = sampler.decide(prepared.event, { durationMs: prepared.event.duration_ms ?? null });
|
|
105
|
+
if (!decision.kept) {
|
|
106
|
+
recorder.counters.sampled_out += 1;
|
|
107
|
+
recorder.sink?.countDropped?.("sampled_out");
|
|
108
|
+
return { ok: false, reason: "sampled_out" };
|
|
109
|
+
}
|
|
110
|
+
prepared.event.sampling = sampler.metadata();
|
|
111
|
+
const writeResult = recorder.sink?.write?.(prepared.event);
|
|
112
|
+
if (writeResult && writeResult.ok === false) {
|
|
113
|
+
return { ok: false, reason: writeResult.reason ?? "sink_rejected" };
|
|
114
|
+
}
|
|
115
|
+
recorder.counters.recorded += 1;
|
|
116
|
+
return { ok: true, event: prepared.event };
|
|
117
|
+
} catch {
|
|
118
|
+
recorder.counters.errors += 1;
|
|
119
|
+
return { ok: false, reason: "recorder_error" };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function prepare(event, metadata) {
|
|
124
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
125
|
+
return { ok: false, reason: "invalid_event", errors: ["event must be a JSON object"] };
|
|
126
|
+
}
|
|
127
|
+
const prepared = { ...event };
|
|
128
|
+
|
|
129
|
+
// ID / 契约补全:只为身份与 schema 版本赋值,不臆造任何指标
|
|
130
|
+
if (!prepared.schema_version) prepared.schema_version = SCHEMA_VERSION;
|
|
131
|
+
if (!prepared.event_id) prepared.event_id = newId("event");
|
|
132
|
+
if (!prepared.span_id) prepared.span_id = newId("span");
|
|
133
|
+
if (!prepared.trace_id) prepared.trace_id = prepared.span_id;
|
|
134
|
+
if (!prepared.timestamp) prepared.timestamp = nowIsoUtc();
|
|
135
|
+
|
|
136
|
+
// 名称哈希(§6.1:工具/插件/模型名可配置脱敏;§3.2:profile 可哈希)
|
|
137
|
+
if (config.hash_names) {
|
|
138
|
+
const salt = ensureSalt();
|
|
139
|
+
if (prepared.tool && typeof prepared.tool.name === "string") {
|
|
140
|
+
prepared.tool = { ...prepared.tool, name: hashName(prepared.tool.name, salt) };
|
|
141
|
+
}
|
|
142
|
+
if (prepared.plugin && typeof prepared.plugin.name === "string") {
|
|
143
|
+
prepared.plugin = { ...prepared.plugin, name: hashName(prepared.plugin.name, salt) };
|
|
144
|
+
}
|
|
145
|
+
if (prepared.model && typeof prepared.model.name === "string") {
|
|
146
|
+
prepared.model = { ...prepared.model, name: hashName(prepared.model.name, salt) };
|
|
147
|
+
}
|
|
148
|
+
if (prepared.session && typeof prepared.session.profile === "string") {
|
|
149
|
+
prepared.session = { ...prepared.session, profile: hashName(prepared.session.profile, salt) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 内容默认不存在;safe 模式下仅附加脱敏后的 metadata
|
|
154
|
+
if (metadata !== undefined && metadata !== null && config.capture_metadata === "safe") {
|
|
155
|
+
const redactor = ensureRedactor();
|
|
156
|
+
const result = redactor.redactMetadata(metadata);
|
|
157
|
+
prepared.metadata = result.value ?? {};
|
|
158
|
+
prepared.privacy = { ...defaultPrivacy(), redactions: result.redactions, rules: result.rules };
|
|
159
|
+
} else {
|
|
160
|
+
prepared.privacy = defaultPrivacy(); // 默认模式也补 privacy 块
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const check = validateEvent(prepared);
|
|
164
|
+
if (!check.ok) {
|
|
165
|
+
return { ok: false, reason: "invalid_event", errors: check.errors };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, event: prepared };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 订阅适配器事件流(显式接入)。返回解绑函数。 */
|
|
171
|
+
function attach(adapter) {
|
|
172
|
+
if (!adapter || typeof adapter.on !== "function") return () => {};
|
|
173
|
+
return adapter.on("*", (event) => {
|
|
174
|
+
record(event);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function flush() {
|
|
179
|
+
if (typeof recorder.sink?.flush === "function") await recorder.sink.flush();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function close() {
|
|
183
|
+
if (typeof recorder.sink?.close === "function") await recorder.sink.close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function status() {
|
|
187
|
+
return {
|
|
188
|
+
enabled: config.enabled,
|
|
189
|
+
store: config.store,
|
|
190
|
+
counters: { ...recorder.counters },
|
|
191
|
+
capabilities: recorder.capabilities,
|
|
192
|
+
config_summary: {
|
|
193
|
+
// 启动日志只显示已启用能力和存储类型,不打印敏感路径片段(§11)
|
|
194
|
+
enabled: config.enabled,
|
|
195
|
+
store: config.store,
|
|
196
|
+
capture_metadata: config.capture_metadata,
|
|
197
|
+
hash_names: config.hash_names,
|
|
198
|
+
sample_rate: config.sample_rate,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
record,
|
|
205
|
+
attach,
|
|
206
|
+
start,
|
|
207
|
+
flush,
|
|
208
|
+
close,
|
|
209
|
+
status,
|
|
210
|
+
config,
|
|
211
|
+
get sink() { return recorder.sink; },
|
|
212
|
+
get counters() { return recorder.counters; },
|
|
213
|
+
get capabilities() { return recorder.capabilities; },
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 报告渲染(计划 §9)。
|
|
3
|
+
*
|
|
4
|
+
* 文本摘要(§9.1)与 Markdown 报告(§9.2)。所有百分比、分位数和成本
|
|
5
|
+
* 都注明时间范围、样本数和数据完整性;成本恒标注「估算,非账单」。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function fmtMs(value) {
|
|
9
|
+
if (value === null || value === undefined) return "n/a";
|
|
10
|
+
if (value >= 10000) return `${(value / 1000).toFixed(1)}s`;
|
|
11
|
+
return `${value}ms`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fmtNum(value) {
|
|
15
|
+
if (value === null || value === undefined) return "n/a";
|
|
16
|
+
return Number(value).toLocaleString("en-US");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fmtCost(value, currency) {
|
|
20
|
+
if (value === null || value === undefined) return "n/a";
|
|
21
|
+
const symbol = currency === "USD" ? "$" : currency ? `${currency} ` : "";
|
|
22
|
+
return `${symbol}${value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fmtPct(value) {
|
|
26
|
+
if (value === null || value === undefined) return "n/a";
|
|
27
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** CLI 文本摘要(对齐计划 §9.1 示例)。 */
|
|
31
|
+
export function renderTextSummary(summary, { dropped = null, store = "jsonl" } = {}) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
const w = summary.window;
|
|
34
|
+
lines.push(`Telemetry summary (${store})`);
|
|
35
|
+
lines.push(`Window ${w.from ?? "n/a"} .. ${w.to ?? "n/a"}`);
|
|
36
|
+
lines.push(`Events ${fmtNum(summary.data_completeness.events)}`);
|
|
37
|
+
lines.push(`Requests ${fmtNum(summary.requests.total)}`);
|
|
38
|
+
lines.push(`Success ${fmtNum(summary.requests.success)} (${fmtPct(summary.requests.success_rate)})`);
|
|
39
|
+
lines.push(`Errors ${fmtNum(summary.requests.failed)}`);
|
|
40
|
+
lines.push(`Cancelled ${fmtNum(summary.requests.cancelled)}`);
|
|
41
|
+
lines.push(`P95 latency ${fmtMs(summary.requests.latency.p95)} (n=${summary.requests.latency.n}, nearest-rank)`);
|
|
42
|
+
lines.push(`P50 latency ${fmtMs(summary.requests.latency.p50)}`);
|
|
43
|
+
lines.push(`Queue p95 ${fmtMs(summary.requests.queue.p95)} (n=${summary.requests.queue.n})`);
|
|
44
|
+
lines.push(`TTFT p95 ${fmtMs(summary.requests.ttft.p95)} (n=${summary.requests.ttft.n})`);
|
|
45
|
+
lines.push(`Retries ${fmtNum(summary.requests.retries)} Fallbacks ${fmtNum(summary.requests.fallbacks)}`);
|
|
46
|
+
lines.push(`Input tokens ${fmtNum(summary.tokens.input)}`);
|
|
47
|
+
lines.push(`Output tokens ${fmtNum(summary.tokens.output)}`);
|
|
48
|
+
lines.push(`Cached tokens ${fmtNum(summary.tokens.cached)}`);
|
|
49
|
+
lines.push(`Estimated cost ${fmtCost(summary.cost.amount, summary.cost.currency)} (estimate, not a bill)`);
|
|
50
|
+
lines.push(`Tool calls ${fmtNum(summary.data_completeness.tool_calls)}`);
|
|
51
|
+
lines.push(`Plugin calls ${fmtNum(summary.data_completeness.plugin_calls)}`);
|
|
52
|
+
if (dropped) {
|
|
53
|
+
const totalDropped = dropped.dropped_write + dropped.dropped_queue + dropped.dropped_oversize + dropped.dropped_invalid + dropped.sampled_out;
|
|
54
|
+
lines.push(`Dropped events ${fmtNum(totalDropped)} (write=${dropped.dropped_write} queue=${dropped.dropped_queue} oversize=${dropped.dropped_oversize} invalid=${dropped.dropped_invalid} sampled=${dropped.sampled_out}, cumulative)`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** --group-by 文本表。 */
|
|
60
|
+
export function renderGroupedText(rows, groupBy) {
|
|
61
|
+
const lines = [`Grouped by ${groupBy}:`];
|
|
62
|
+
lines.push(`${"group".padEnd(28)} ${"events".padStart(7)} ${"req".padStart(6)} ${"ok".padStart(6)} ${"err".padStart(5)} ${"p95".padStart(10)} ${"in_tok".padStart(10)} ${"out_tok".padStart(10)} ${"cost".padStart(12)}`);
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
lines.push(
|
|
65
|
+
[
|
|
66
|
+
String(row.group).slice(0, 27).padEnd(28),
|
|
67
|
+
String(row.events).padStart(7),
|
|
68
|
+
String(row.requests).padStart(6),
|
|
69
|
+
String(row.success).padStart(6),
|
|
70
|
+
String(row.failed).padStart(5),
|
|
71
|
+
fmtMs(row.p95_ms).padStart(10),
|
|
72
|
+
fmtNum(row.input_tokens).padStart(10),
|
|
73
|
+
fmtNum(row.output_tokens).padStart(10),
|
|
74
|
+
fmtCost(row.cost).padStart(12),
|
|
75
|
+
].join(" ")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** --trace 文本视图(树 + 时间线)。 */
|
|
82
|
+
export function renderTraceText(view) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
lines.push(`Trace ${view.trace_id ?? "(unknown)"}${view.request_id ? ` request_id=${view.request_id}` : ""}`);
|
|
85
|
+
lines.push("");
|
|
86
|
+
const renderNode = (node, depth) => {
|
|
87
|
+
const head = node.events[0] ?? {};
|
|
88
|
+
const label = head.tool ?? head.plugin ?? head.model ?? "";
|
|
89
|
+
const duration = node.events.find((e) => Number.isInteger(e.duration_ms))?.duration_ms ?? null;
|
|
90
|
+
lines.push(`${" ".repeat(depth)}- ${node.span_id} ${label ? `[${label}] ` : ""}${duration !== null ? fmtMs(duration) : ""}`);
|
|
91
|
+
for (const child of node.children) renderNode(child, depth + 1);
|
|
92
|
+
};
|
|
93
|
+
for (const root of view.tree.roots) renderNode(root, 0);
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push("Timeline:");
|
|
96
|
+
for (const item of view.timeline) {
|
|
97
|
+
const label = item.tool ?? item.plugin ?? item.model ?? "";
|
|
98
|
+
const status = item.status ? ` status=${item.status}` : "";
|
|
99
|
+
const duration = item.duration_ms !== null ? ` dur=${item.duration_ms}ms` : "";
|
|
100
|
+
lines.push(` ${item.timestamp} ${item.event.padEnd(18)} ${label}${status}${duration}`);
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Markdown 报告(计划 §9.2 固定章节结构)。
|
|
107
|
+
* @param {object} summary aggregateEvents 结果
|
|
108
|
+
* @param {object} meta { store, path, dropped, command, priceCatalogPath }
|
|
109
|
+
*/
|
|
110
|
+
export function renderMarkdownReport(summary, meta = {}) {
|
|
111
|
+
const s = summary;
|
|
112
|
+
const out = [];
|
|
113
|
+
out.push("# Harness 遥测报告");
|
|
114
|
+
out.push("");
|
|
115
|
+
out.push(`> 由 \`dsh-local-telemetry\` 生成于 ${s.generated_at};成本为估算值,非实际账单。`);
|
|
116
|
+
out.push("");
|
|
117
|
+
|
|
118
|
+
out.push("## 1. 时间范围与数据完整性");
|
|
119
|
+
out.push("");
|
|
120
|
+
out.push(`- 存储后端:${meta.store ?? "jsonl"}(${meta.path ?? "n/a"})`);
|
|
121
|
+
out.push(`- 事件时间范围:${s.window.from ?? "n/a"} → ${s.window.to ?? "n/a"}`);
|
|
122
|
+
out.push(`- 事件数:${s.data_completeness.events}(请求 ${s.data_completeness.requests},模型 attempt ${s.data_completeness.model_attempts},工具调用 ${s.data_completeness.tool_calls},插件调用 ${s.data_completeness.plugin_calls})`);
|
|
123
|
+
if (meta.dropped) {
|
|
124
|
+
const totalDropped = meta.dropped.dropped_write + meta.dropped.dropped_queue + meta.dropped.dropped_oversize + meta.dropped.dropped_invalid + meta.dropped.sampled_out;
|
|
125
|
+
out.push(`- 丢弃事件(累计):${totalDropped}(write=${meta.dropped.dropped_write},queue=${meta.dropped.dropped_queue},oversize=${meta.dropped.dropped_oversize},invalid=${meta.dropped.dropped_invalid},sampled=${meta.dropped.sampled_out})`);
|
|
126
|
+
}
|
|
127
|
+
out.push(`- 分位数方法:nearest-rank;时长来自 started/completed span 配对;null 表示未知,不补 0。`);
|
|
128
|
+
out.push("");
|
|
129
|
+
|
|
130
|
+
out.push("## 2. 请求和错误概览");
|
|
131
|
+
out.push("");
|
|
132
|
+
out.push(`- 请求总数 ${s.requests.total};成功 ${s.requests.success}(${fmtPct(s.requests.success_rate)});失败 ${s.requests.failed};取消 ${s.requests.cancelled};未闭合 ${s.requests.incomplete}`);
|
|
133
|
+
const kinds = Object.entries(s.errors.kinds);
|
|
134
|
+
out.push(`- 错误分类:${kinds.length === 0 ? "无" : kinds.map(([k, n]) => `${k}=${n}`).join("、")}`);
|
|
135
|
+
out.push(`- 重试 ${s.requests.retries} 次;模型回退 ${s.requests.fallbacks} 次`);
|
|
136
|
+
out.push("");
|
|
137
|
+
|
|
138
|
+
out.push("## 3. 延迟分布");
|
|
139
|
+
out.push("");
|
|
140
|
+
out.push(`| 指标 | p50 | p95 | p99 | avg | n |`);
|
|
141
|
+
out.push(`| --- | --- | --- | --- | --- | --- |`);
|
|
142
|
+
out.push(`| 请求总耗时 | ${fmtMs(s.requests.latency.p50)} | ${fmtMs(s.requests.latency.p95)} | ${fmtMs(s.requests.latency.p99)} | ${fmtMs(s.requests.latency.avg)} | ${s.requests.latency.n} |`);
|
|
143
|
+
out.push(`| 排队耗时 | ${fmtMs(s.requests.queue.p50)} | ${fmtMs(s.requests.queue.p95)} | ${fmtMs(s.requests.queue.p99)} | ${fmtMs(s.requests.queue.avg)} | ${s.requests.queue.n} |`);
|
|
144
|
+
out.push(`| 首 Token 延迟 | ${fmtMs(s.requests.ttft.p50)} | ${fmtMs(s.requests.ttft.p95)} | ${fmtMs(s.requests.ttft.p99)} | ${fmtMs(s.requests.ttft.avg)} | ${s.requests.ttft.n} |`);
|
|
145
|
+
out.push("");
|
|
146
|
+
|
|
147
|
+
out.push("## 4. Token 与成本");
|
|
148
|
+
out.push("");
|
|
149
|
+
out.push(`- 输入 Token:${fmtNum(s.tokens.input)};输出 Token:${fmtNum(s.tokens.output)};缓存命中 Token:${fmtNum(s.tokens.cached)}(含 usage 的事件 ${s.tokens.events_with_usage})`);
|
|
150
|
+
out.push(`- 估算成本:${fmtCost(s.cost.amount, s.cost.currency)}(来源:${s.cost.source ?? "未配置价格目录"},生效时间 ${s.cost.effective_at ?? "n/a"})`);
|
|
151
|
+
const missing = Object.entries(s.cost.missing);
|
|
152
|
+
out.push(`- 未计价原因:${missing.length === 0 ? "无" : missing.map(([k, n]) => `${k}=${n}`).join("、")}`);
|
|
153
|
+
out.push("");
|
|
154
|
+
|
|
155
|
+
out.push("## 5. 模型比较");
|
|
156
|
+
out.push("");
|
|
157
|
+
if (s.models.length === 0) {
|
|
158
|
+
out.push("_窗口内无模型调用。_");
|
|
159
|
+
} else {
|
|
160
|
+
out.push(`| provider | model | type | attempts | 成功率 | p95 延迟 | TTFT p95 | in/out/cached tok | 估算成本 |`);
|
|
161
|
+
out.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
|
|
162
|
+
for (const m of s.models) {
|
|
163
|
+
out.push(`| ${m.provider ?? "?"} | ${m.name ?? "?"} | ${m.request_type ?? "?"} | ${m.attempts} | ${fmtPct(m.success_rate)} | ${fmtMs(m.latency.p95)} | ${fmtMs(m.ttft.p95)} | ${fmtNum(m.tokens.input)}/${fmtNum(m.tokens.output)}/${fmtNum(m.tokens.cached)} | ${fmtCost(m.cost.amount, s.cost.currency)} |`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
out.push("");
|
|
167
|
+
|
|
168
|
+
out.push("## 6. 工具与插件耗时");
|
|
169
|
+
out.push("");
|
|
170
|
+
if (s.tools.length === 0) out.push("_窗口内无工具调用。_");
|
|
171
|
+
else {
|
|
172
|
+
out.push(`| 工具 | 调用 | 成功 | 失败 | 超时 | p50 | p95 | 需确认 |`);
|
|
173
|
+
out.push(`| --- | --- | --- | --- | --- | --- | --- | --- |`);
|
|
174
|
+
for (const t of s.tools) {
|
|
175
|
+
out.push(`| ${t.name} | ${t.calls} | ${t.success} | ${t.failed} | ${t.timeout} | ${fmtMs(t.latency.p50)} | ${fmtMs(t.latency.p95)} | ${t.confirm_required} |`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
out.push("");
|
|
179
|
+
if (s.plugins.length === 0) out.push("_窗口内无插件调用。_");
|
|
180
|
+
else {
|
|
181
|
+
out.push(`| 插件 | hook | 调用 | 错误 | p95 |`);
|
|
182
|
+
out.push(`| --- | --- | --- | --- | --- |`);
|
|
183
|
+
for (const p of s.plugins) {
|
|
184
|
+
const hookEntries = Object.entries(p.hooks);
|
|
185
|
+
if (hookEntries.length === 0) out.push(`| ${p.name} | - | - | ${p.errors} | ${fmtMs(p.latency.p95)} |`);
|
|
186
|
+
else for (const [hook, data] of hookEntries) out.push(`| ${p.name} | ${hook} | ${data.calls} | ${data.errors} | ${fmtMs(data.latency.p95)} |`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
out.push("");
|
|
190
|
+
|
|
191
|
+
out.push("## 7. 慢请求和失败请求");
|
|
192
|
+
out.push("");
|
|
193
|
+
out.push(`> 以 \`--slow-over-ms\` / \`--errors-only\` 过滤后重新运行可获取完整明细;本节列出当前窗口内的失败请求 trace。`);
|
|
194
|
+
out.push("");
|
|
195
|
+
const failedTraces = s.requests.failed > 0 || s.requests.cancelled > 0 ? "见 `--summary --errors-only --group-by day` 输出与 `--trace` 明细。" : "无失败请求。";
|
|
196
|
+
out.push(failedTraces);
|
|
197
|
+
out.push("");
|
|
198
|
+
|
|
199
|
+
out.push("## 8. 缓存、重试和回退");
|
|
200
|
+
out.push("");
|
|
201
|
+
out.push(`- 缓存命中 Token:${fmtNum(s.tokens.cached)}(占输入 ${s.tokens.input > 0 ? fmtPct(s.tokens.cached / s.tokens.input) : "n/a"})`);
|
|
202
|
+
out.push(`- 重试:${s.requests.retries};模型回退:${s.requests.fallbacks}`);
|
|
203
|
+
out.push("");
|
|
204
|
+
|
|
205
|
+
out.push("## 9. 数据隐私与丢弃事件");
|
|
206
|
+
out.push("");
|
|
207
|
+
out.push("- 本插件默认不采集 prompt、response、文件内容、命令参数与环境变量;成本与 Token 均为宿主/模型返回的真实 usage。");
|
|
208
|
+
out.push("- 事件中的名称(模型/工具/插件/profile)可配置哈希化;metadata 仅在显式开启 safe 模式并脱敏后附加。");
|
|
209
|
+
out.push(`- 丢弃事件累计:${meta.dropped ? meta.dropped.dropped_write + meta.dropped.dropped_queue + meta.dropped.dropped_oversize + meta.dropped.dropped_invalid + meta.dropped.sampled_out : "n/a"}(见第 1 节明细)。`);
|
|
210
|
+
out.push("");
|
|
211
|
+
|
|
212
|
+
out.push("## 附录");
|
|
213
|
+
out.push("");
|
|
214
|
+
out.push(`- 生成命令:\`${meta.command ?? "node bin/telemetry.mjs --summary --format markdown"}\``);
|
|
215
|
+
out.push("- 事件 schema:`docs/schema.md`(schema_version 1.0)");
|
|
216
|
+
out.push(`- 价格目录:${meta.priceCatalogPath ?? "未配置"}(版本化,见计划 §5)`);
|
|
217
|
+
out.push(`- 工具版本:${s.tool.version};schema_version:${s.schema_version}`);
|
|
218
|
+
out.push("");
|
|
219
|
+
return out.join("\n");
|
|
220
|
+
}
|
package/src/sampling.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 采样(计划 §7.3)。
|
|
3
|
+
*
|
|
4
|
+
* 决策以 trace 为粒度(同一 trace_id 的所有事件同决策,trace 不断裂),
|
|
5
|
+
* 通过 salted hash 把 trace_id 确定性映射到 [0,1) 与 sample_rate 比较。
|
|
6
|
+
* 错误、取消与慢请求默认绕过采样;丢弃时把原因计入 sink 计数器。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
export const SAMPLING_STRATEGY = "per-trace-hash-v1";
|
|
12
|
+
|
|
13
|
+
/** trace_id → [0,1) 的确定性映射。 */
|
|
14
|
+
export function traceUnit(traceId, salt) {
|
|
15
|
+
const digest = createHash("sha256").update(`${salt}\u0000${traceId ?? ""}`).digest();
|
|
16
|
+
return digest.readUInt32BE(0) / 0x1_0000_0000;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {number} opts.sampleRate 0..1
|
|
22
|
+
* @param {boolean} opts.errorsAlwaysSample
|
|
23
|
+
* @param {number} [opts.slowRequestMs] 慢请求阈值;请求级 duration 超过则保留
|
|
24
|
+
* @param {string} opts.salt 哈希 salt(与名称哈希共用同一数据目录 salt)
|
|
25
|
+
*/
|
|
26
|
+
export function createSampler({ sampleRate, errorsAlwaysSample = true, slowRequestMs = null, salt }) {
|
|
27
|
+
const rate = Math.min(Math.max(sampleRate ?? 1, 0), 1);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @returns {{ kept: boolean, reason: "always-error"|"slow"|"rate"|"sampled-out" }}
|
|
31
|
+
*/
|
|
32
|
+
function decide(event, { durationMs = null } = {}) {
|
|
33
|
+
const status = event.result?.status;
|
|
34
|
+
const isError =
|
|
35
|
+
event.event === "model.failed" || event.event === "request.cancelled" || status === "failed" || status === "timeout" || status === "cancelled";
|
|
36
|
+
if (errorsAlwaysSample && isError) return { kept: true, reason: "always-error" };
|
|
37
|
+
if (slowRequestMs !== null && Number.isFinite(durationMs) && durationMs >= slowRequestMs) {
|
|
38
|
+
return { kept: true, reason: "slow" };
|
|
39
|
+
}
|
|
40
|
+
if (traceUnit(event.trace_id, salt) < rate) return { kept: true, reason: "rate" };
|
|
41
|
+
return { kept: false, reason: "sampled-out" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 采样元数据写入事件(计划 §7.3:采样配置必须写入事件元数据)。 */
|
|
45
|
+
function metadata() {
|
|
46
|
+
return { rate, strategy: SAMPLING_STRATEGY, errors_always_sample: Boolean(errorsAlwaysSample) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { decide, metadata };
|
|
50
|
+
}
|