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,677 @@
1
+ /**
2
+ * dsh-local-telemetry — 聚合器(计划 §4 / Phase 2)。
3
+ *
4
+ * 只从真实事件推导:started/completed 按 span_id 配对得时长(缺失端点则
5
+ * null,绝不臆造);未知指标为 null 不补 0;所有样本数与分位数方法
6
+ * (nearest-rank)随摘要输出,保证可由原始事件重算(Phase 2 验收)。
7
+ */
8
+
9
+ import { computeCost, sumCosts } from "./cost.mjs";
10
+
11
+ /** nearest-rank 分位数:sorted 已升序;n=0 → null。方法固定并写入文档。 */
12
+ export function percentile(sorted, p) {
13
+ if (!Array.isArray(sorted) || sorted.length === 0) return null;
14
+ const n = sorted.length;
15
+ const rank = Math.ceil((p / 100) * n);
16
+ const index = Math.min(Math.max(rank, 1), n) - 1;
17
+ return sorted[index];
18
+ }
19
+
20
+ function ms(value) {
21
+ return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
22
+ }
23
+
24
+ function isFailureStatus(status) {
25
+ return status === "failed" || status === "timeout";
26
+ }
27
+
28
+ function eventTs(event) {
29
+ const t = Date.parse(event.timestamp ?? "");
30
+ return Number.isFinite(t) ? t : null;
31
+ }
32
+
33
+ /**
34
+ * started/completed 通用配对:优先 span_id 精确配对;
35
+ * 兜底在同 trace 内与「最近的未闭合同名 started」配对(宿主不保证 span 传播时)。
36
+ * 返回 { pairs: [{start, end}], unmatched: [started...] }
37
+ */
38
+ export function pairSpans(events, { startedName, endNames, nameOf }) {
39
+ const open = new Map(); // span_id -> start event
40
+ const openByNameTrace = new Map(); // `${trace}\u0000${name}` -> [start events]
41
+ const pairs = [];
42
+ const unmatched = [];
43
+ const sorted = [...events].sort((a, b) => (eventTs(a) ?? 0) - (eventTs(b) ?? 0));
44
+
45
+ for (const event of sorted) {
46
+ if (event.event === startedName) {
47
+ open.set(event.span_id, event);
48
+ if (nameOf) {
49
+ const key = `${event.trace_id ?? ""}\u0000${nameOf(event)}`;
50
+ if (!openByNameTrace.has(key)) openByNameTrace.set(key, []);
51
+ openByNameTrace.get(key).push(event);
52
+ }
53
+ continue;
54
+ }
55
+ if (!endNames.includes(event.event)) continue;
56
+ let start = open.get(event.span_id) ?? null;
57
+ if (start) {
58
+ open.delete(event.span_id);
59
+ if (nameOf) {
60
+ const key = `${start.trace_id ?? ""}\u0000${nameOf(start)}`;
61
+ const list = openByNameTrace.get(key);
62
+ if (list) {
63
+ const idx = list.indexOf(start);
64
+ if (idx >= 0) list.splice(idx, 1);
65
+ }
66
+ }
67
+ } else if (nameOf) {
68
+ const key = `${event.trace_id ?? ""}\u0000${nameOf(event)}`;
69
+ const list = openByNameTrace.get(key);
70
+ start = list?.shift() ?? null;
71
+ }
72
+ if (start) pairs.push({ start, end: event });
73
+ else unmatched.push(event);
74
+ }
75
+ for (const list of openByNameTrace.values()) unmatched.push(...list);
76
+ return { pairs, unmatched };
77
+ }
78
+
79
+ function spanDuration(pair) {
80
+ if (Number.isInteger(pair.end.duration_ms)) return pair.end.duration_ms;
81
+ const startTs = eventTs(pair.start);
82
+ const endTs = eventTs(pair.end);
83
+ if (startTs === null || endTs === null) return null;
84
+ return Math.max(0, endTs - startTs);
85
+ }
86
+
87
+ /** status 推断:completed 事件显式 status 优先,failed/cancelled 按事件名。 */
88
+ function endStatus(end) {
89
+ if (end.result?.status) return end.result.status;
90
+ if (end.event === "model.failed") return "failed";
91
+ if (end.event === "request.cancelled") return "cancelled";
92
+ return "success";
93
+ }
94
+
95
+ function emptyPercentiles() {
96
+ return { p50: null, p95: null, p99: null, avg: null, n: 0 };
97
+ }
98
+
99
+ function durationStats(values) {
100
+ const valid = values.filter((v) => Number.isFinite(v) && v >= 0).map(Math.round).sort((a, b) => a - b);
101
+ if (valid.length === 0) return emptyPercentiles();
102
+ const sum = valid.reduce((acc, v) => acc + v, 0);
103
+ return { p50: percentile(valid, 50), p95: percentile(valid, 95), p99: percentile(valid, 99), avg: Math.round(sum / valid.length), n: valid.length };
104
+ }
105
+
106
+ /**
107
+ * 主聚合入口。events 已经过时间/维度过滤。
108
+ * @param {Array} events
109
+ * @param {object} opts
110
+ * @param {object|null} [opts.catalog] 版本化价格目录(loadPriceCatalog 结果)
111
+ * @param {string|null} [opts.catalogPath] 价格目录路径(仅用于来源标注)
112
+ * @param {string} [opts.generatedAt] 摘要生成时间
113
+ */
114
+ export function aggregateEvents(events, { catalog = null, catalogPath = null, generatedAt = null } = {}) {
115
+ const generated = generatedAt ?? new Date().toISOString();
116
+ const sorted = [...events].sort((a, b) => (eventTs(a) ?? 0) - (eventTs(b) ?? 0));
117
+
118
+ // ---------- 请求级 ----------
119
+ const byTrace = new Map();
120
+ for (const event of sorted) {
121
+ const trace = event.trace_id ?? event.span_id;
122
+ if (!byTrace.has(trace)) byTrace.set(trace, []);
123
+ byTrace.get(trace).push(event);
124
+ }
125
+
126
+ const requests = [];
127
+ for (const [trace, traceEvents] of byTrace) {
128
+ const started = traceEvents.find((e) => e.event === "request.started");
129
+ const terminal = traceEvents.find((e) => e.event === "request.completed" || e.event === "request.cancelled");
130
+ if (!started && !terminal) continue; // 非 request 根 trace(如孤立工具 trace)
131
+ const status = terminal ? endStatus(terminal) : "incomplete";
132
+ let durationMs = null;
133
+ if (Number.isInteger(terminal?.duration_ms)) durationMs = terminal.duration_ms;
134
+ else {
135
+ const startTs = started ? eventTs(started) : null;
136
+ const endTs = terminal ? eventTs(terminal) : null;
137
+ if (startTs !== null && endTs !== null) durationMs = Math.max(0, endTs - startTs);
138
+ }
139
+
140
+ const firstRequested = traceEvents.find((e) => e.event === "model.requested");
141
+ let queueMs = null;
142
+ if (started && firstRequested) {
143
+ const s = eventTs(started);
144
+ const r = eventTs(firstRequested);
145
+ if (s !== null && r !== null) queueMs = Math.max(0, r - s);
146
+ }
147
+
148
+ const attemptEvents = traceEvents.filter((e) => e.event === "model.requested");
149
+ const attempts = attemptEvents.length;
150
+ const retries = attempts > 1 ? attempts - 1 : 0;
151
+ let fallbacks = 0;
152
+ for (let i = 1; i < attemptEvents.length; i += 1) {
153
+ const prev = attemptEvents[i - 1]?.model?.name;
154
+ const curr = attemptEvents[i]?.model?.name;
155
+ if (prev && curr && prev !== curr) fallbacks += 1;
156
+ }
157
+ const firstToken = traceEvents.find((e) => e.event === "model.first_token");
158
+ let ttftMs = null;
159
+ if (firstToken && firstRequested) {
160
+ const r = eventTs(firstRequested);
161
+ const t = eventTs(firstToken);
162
+ if (r !== null && t !== null) ttftMs = Math.max(0, t - r);
163
+ }
164
+ requests.push({
165
+ trace_id: trace,
166
+ request_id: started?.request_id ?? terminal?.request_id ?? null,
167
+ profile: started?.session?.profile ?? terminal?.session?.profile ?? null,
168
+ status,
169
+ duration_ms: ms(durationMs),
170
+ queue_ms: ms(queueMs),
171
+ ttft_ms: ms(ttftMs),
172
+ model_attempts: attempts,
173
+ retries,
174
+ fallbacks,
175
+ });
176
+ }
177
+
178
+ const requestDurations = requests.map((r) => r.duration_ms).filter((v) => v !== null);
179
+ const queueValues = requests.map((r) => r.queue_ms).filter((v) => v !== null);
180
+ const ttftValues = requests.map((r) => r.ttft_ms).filter((v) => v !== null);
181
+ const successCount = requests.filter((r) => r.status === "success").length;
182
+ const failedCount = requests.filter((r) => r.status === "failed" || r.status === "timeout").length;
183
+ const cancelledCount = requests.filter((r) => r.status === "cancelled").length;
184
+
185
+ // ---------- 模型级 ----------
186
+ const modelPairs = pairSpans(sorted, {
187
+ startedName: "model.requested",
188
+ endNames: ["model.completed", "model.failed"],
189
+ nameOf: (e) => `${e.model?.provider ?? ""}/${e.model?.name ?? ""}/${e.model?.request_type ?? ""}`,
190
+ });
191
+
192
+ const modelMap = new Map();
193
+ const modelCostResults = [];
194
+ for (const pair of modelPairs.pairs) {
195
+ const key = `${pair.start.model?.provider ?? "unknown"}\u0000${pair.start.model?.name ?? "unknown"}\u0000${pair.start.model?.request_type ?? "chat"}`;
196
+ if (!modelMap.has(key)) {
197
+ modelMap.set(key, {
198
+ provider: pair.start.model?.provider ?? null,
199
+ name: pair.start.model?.name ?? null,
200
+ request_type: pair.start.model?.request_type ?? null,
201
+ attempts: 0,
202
+ success: 0,
203
+ failed: 0,
204
+ timeout: 0,
205
+ cancelled: 0,
206
+ durations: [],
207
+ ttfts: [],
208
+ tokens: { input: 0, output: 0, cached: 0, known: 0 },
209
+ retries: 0,
210
+ error_kinds: {},
211
+ cost_results: [],
212
+ });
213
+ }
214
+ const bucket = modelMap.get(key);
215
+ bucket.attempts += 1;
216
+ const status = endStatus(pair.end);
217
+ if (status === "success") bucket.success += 1;
218
+ else if (status === "timeout") bucket.timeout += 1;
219
+ else if (status === "cancelled") bucket.cancelled += 1;
220
+ else bucket.failed += 1;
221
+ const kind = pair.end.error?.kind;
222
+ if (kind) bucket.error_kinds[kind] = (bucket.error_kinds[kind] ?? 0) + 1;
223
+
224
+ const duration = spanDuration(pair);
225
+ if (duration !== null) bucket.durations.push(duration);
226
+
227
+ const firstToken = sorted.find((e) => e.event === "model.first_token" && e.span_id === pair.start.span_id);
228
+ if (firstToken) {
229
+ const r = eventTs(pair.start);
230
+ const t = eventTs(firstToken);
231
+ if (r !== null && t !== null) bucket.ttfts.push(Math.max(0, t - r));
232
+ }
233
+ const usage = pair.end.usage;
234
+ if (usage && (Number.isInteger(usage.input_tokens) || Number.isInteger(usage.output_tokens))) {
235
+ bucket.tokens.known += 1;
236
+ bucket.tokens.input += Number.isInteger(usage.input_tokens) ? usage.input_tokens : 0;
237
+ bucket.tokens.output += Number.isInteger(usage.output_tokens) ? usage.output_tokens : 0;
238
+ bucket.tokens.cached += Number.isInteger(usage.cached_input_tokens) ? usage.cached_input_tokens : 0;
239
+ }
240
+ const cost = computeCost({ model: pair.end.model ?? pair.start.model, usage: pair.end.usage, catalog });
241
+ bucket.cost_results.push(cost);
242
+ modelCostResults.push(cost);
243
+ }
244
+ // 未闭合 attempt(cancelled 请求中的悬挂调用)
245
+ for (const orphan of modelPairs.unmatched) {
246
+ const key = `${orphan.model?.provider ?? "unknown"}\u0000${orphan.model?.name ?? "unknown"}\u0000${orphan.model?.request_type ?? "chat"}`;
247
+ if (!modelMap.has(key)) {
248
+ modelMap.set(key, {
249
+ provider: orphan.model?.provider ?? null,
250
+ name: orphan.model?.name ?? null,
251
+ request_type: orphan.model?.request_type ?? null,
252
+ attempts: 0, success: 0, failed: 0, timeout: 0, cancelled: 0,
253
+ durations: [], ttfts: [], tokens: { input: 0, output: 0, cached: 0, known: 0 }, retries: 0, error_kinds: {}, cost_results: [],
254
+ });
255
+ }
256
+ const bucket = modelMap.get(key);
257
+ bucket.attempts += 1;
258
+ bucket.cancelled += 1; // 请求被取消时未闭合的 attempt 计为 cancelled
259
+ }
260
+ // 重试归属:trace 内同模型多次 attempt
261
+ for (const [trace, traceEvents] of byTrace) {
262
+ const perModel = new Map();
263
+ for (const event of traceEvents) {
264
+ if (event.event !== "model.requested") continue;
265
+ const key = `${event.model?.provider ?? ""}\u0000${event.model?.name ?? ""}`;
266
+ perModel.set(key, (perModel.get(key) ?? 0) + 1);
267
+ }
268
+ for (const [key, count] of perModel) {
269
+ if (count <= 1) continue;
270
+ const bucket = modelMap.get(`${key}\u0000${traceEvents.find((e) => e.event === "model.requested")?.model?.request_type ?? "chat"}`);
271
+ if (bucket) bucket.retries += count - 1;
272
+ }
273
+ }
274
+
275
+ const models = [...modelMap.values()].map((bucket) => {
276
+ const costSummary = sumCosts(bucket.cost_results);
277
+ const attempts = bucket.attempts;
278
+ return {
279
+ provider: bucket.provider,
280
+ name: bucket.name,
281
+ request_type: bucket.request_type,
282
+ attempts,
283
+ success: bucket.success,
284
+ failed: bucket.failed,
285
+ timeout: bucket.timeout,
286
+ cancelled: bucket.cancelled,
287
+ success_rate: attempts > 0 ? round4(bucket.success / attempts) : null,
288
+ latency: durationStats(bucket.durations),
289
+ ttft: durationStats(bucket.ttfts),
290
+ tokens: {
291
+ input: bucket.tokens.input,
292
+ output: bucket.tokens.output,
293
+ cached: bucket.tokens.cached,
294
+ events_with_usage: bucket.tokens.known,
295
+ },
296
+ retries: bucket.retries,
297
+ error_kinds: bucket.error_kinds,
298
+ cost: {
299
+ amount: costSummary.amount,
300
+ priced_events: costSummary.priced_events,
301
+ missing: costSummary.missing,
302
+ },
303
+ };
304
+ }).sort((a, b) => b.attempts - a.attempts);
305
+
306
+ // ---------- Token 总量 ----------
307
+ let tokens = { input: 0, output: 0, cached: 0, events_with_usage: 0 };
308
+ for (const event of sorted) {
309
+ if (event.event !== "model.completed") continue;
310
+ const usage = event.usage;
311
+ if (usage && (Number.isInteger(usage.input_tokens) || Number.isInteger(usage.output_tokens))) {
312
+ tokens.events_with_usage += 1;
313
+ tokens.input += Number.isInteger(usage.input_tokens) ? usage.input_tokens : 0;
314
+ tokens.output += Number.isInteger(usage.output_tokens) ? usage.output_tokens : 0;
315
+ tokens.cached += Number.isInteger(usage.cached_input_tokens) ? usage.cached_input_tokens : 0;
316
+ }
317
+ }
318
+
319
+ // ---------- 成本 ----------
320
+ const costSummary = sumCosts(modelCostResults);
321
+
322
+ // ---------- 工具级 ----------
323
+ const toolPairs = pairSpans(sorted, { startedName: "tool.started", endNames: ["tool.completed"], nameOf: (e) => e.tool?.name ?? "unknown" });
324
+ const toolMap = new Map();
325
+ const toolLoops = new Map(); // trace -> Map(tool -> count)
326
+ for (const event of sorted) {
327
+ if (event.event !== "tool.started") continue;
328
+ const trace = event.trace_id ?? "";
329
+ const name = event.tool?.name ?? "unknown";
330
+ if (!toolLoops.has(trace)) toolLoops.set(trace, new Map());
331
+ const perTool = toolLoops.get(trace);
332
+ perTool.set(name, (perTool.get(name) ?? 0) + 1);
333
+ }
334
+ for (const pair of toolPairs.pairs) {
335
+ const name = pair.start.tool?.name ?? "unknown";
336
+ if (!toolMap.has(name)) {
337
+ toolMap.set(name, {
338
+ name, calls: 0, success: 0, failed: 0, timeout: 0, durations: [], confirm_required: 0,
339
+ input_bytes: null, output_bytes: null,
340
+ });
341
+ }
342
+ const bucket = toolMap.get(name);
343
+ bucket.calls += 1;
344
+ const status = endStatus(pair.end);
345
+ if (status === "success") bucket.success += 1;
346
+ else if (status === "timeout") bucket.timeout += 1;
347
+ else bucket.failed += 1;
348
+ const duration = spanDuration(pair);
349
+ if (duration !== null) bucket.durations.push(duration);
350
+ const metadata = pair.end.metadata ?? pair.start.metadata;
351
+ if (metadata && typeof metadata === "object") {
352
+ if (metadata.confirm_required === true) bucket.confirm_required += 1;
353
+ if (Number.isFinite(metadata.input_bytes)) bucket.input_bytes = Math.max(bucket.input_bytes ?? 0, Math.round(metadata.input_bytes));
354
+ if (Number.isFinite(metadata.output_bytes)) bucket.output_bytes = Math.max(bucket.output_bytes ?? 0, Math.round(metadata.output_bytes));
355
+ }
356
+ }
357
+ const tools = [...toolMap.values()].map((bucket) => {
358
+ const stats = durationStats(bucket.durations);
359
+ return {
360
+ name: bucket.name,
361
+ calls: bucket.calls,
362
+ unclosed: 0,
363
+ success: bucket.success,
364
+ failed: bucket.failed,
365
+ timeout: bucket.timeout,
366
+ latency: stats,
367
+ confirm_required: bucket.confirm_required,
368
+ max_input_bytes: bucket.input_bytes,
369
+ max_output_bytes: bucket.output_bytes,
370
+ };
371
+ }).sort((a, b) => b.calls - a.calls);
372
+ // 循环调用提示:同 trace 内同名工具调用 ≥ 3 次
373
+ let loopHints = [];
374
+ for (const [trace, perTool] of toolLoops) {
375
+ for (const [name, count] of perTool) {
376
+ if (count >= 3) loopHints.push({ trace_id: trace, tool: name, calls: count });
377
+ }
378
+ }
379
+ loopHints = loopHints.sort((a, b) => b.calls - a.calls).slice(0, 20);
380
+ for (const tool of tools) {
381
+ tool.unclosed = toolPairs.unmatched.filter((e) => (e.tool?.name ?? "unknown") === tool.name).length;
382
+ }
383
+
384
+ // ---------- 插件级 ----------
385
+ const pluginPairs = pairSpans(sorted, { startedName: "plugin.started", endNames: ["plugin.completed"], nameOf: (e) => `${e.plugin?.name ?? "unknown"}\u0000${e.plugin?.hook ?? ""}` });
386
+ const pluginMap = new Map();
387
+ for (const pair of pluginPairs.pairs) {
388
+ const name = pair.start.plugin?.name ?? "unknown";
389
+ const hook = pair.start.plugin?.hook ?? null;
390
+ if (!pluginMap.has(name)) {
391
+ pluginMap.set(name, {
392
+ name, hooks: {}, durations: [], errors: 0, error_kinds: {}, timeout: 0, cancelled: 0,
393
+ metadata_changed: 0,
394
+ });
395
+ }
396
+ const bucket = pluginMap.get(name);
397
+ if (hook) {
398
+ if (!bucket.hooks[hook]) bucket.hooks[hook] = { calls: 0, durations: [], errors: 0 };
399
+ bucket.hooks[hook].calls += 1;
400
+ const duration = spanDuration(pair);
401
+ if (duration !== null) {
402
+ bucket.hooks[hook].durations.push(duration);
403
+ bucket.durations.push(duration);
404
+ }
405
+ } else {
406
+ const duration = spanDuration(pair);
407
+ if (duration !== null) bucket.durations.push(duration);
408
+ }
409
+ const status = endStatus(pair.end);
410
+ if (status === "failed") {
411
+ bucket.errors += 1;
412
+ if (hook) bucket.hooks[hook].errors += 1;
413
+ const kind = pair.end.error?.kind ?? "unknown";
414
+ bucket.error_kinds[kind] = (bucket.error_kinds[kind] ?? 0) + 1;
415
+ } else if (status === "timeout") bucket.timeout += 1;
416
+ else if (status === "cancelled") bucket.cancelled += 1;
417
+ const metadata = pair.end.metadata ?? pair.start.metadata;
418
+ if (metadata?.changed === true) bucket.metadata_changed += 1;
419
+ }
420
+ const plugins = [...pluginMap.values()].map((bucket) => ({
421
+ name: bucket.name,
422
+ hooks: Object.fromEntries(
423
+ Object.entries(bucket.hooks).map(([hook, data]) => [
424
+ hook,
425
+ { calls: data.calls, errors: data.errors, latency: durationStats(data.durations) },
426
+ ])
427
+ ),
428
+ latency: durationStats(bucket.durations),
429
+ errors: bucket.errors,
430
+ error_kinds: bucket.error_kinds,
431
+ timeout: bucket.timeout,
432
+ cancelled: bucket.cancelled,
433
+ metadata_changed: bucket.metadata_changed,
434
+ })).sort((a, b) => a.name.localeCompare(b.name));
435
+
436
+ // ---------- 错误分类 ----------
437
+ const errorKinds = {};
438
+ for (const event of sorted) {
439
+ if (event.event === "model.failed" || isFailureStatus(event.result?.status)) {
440
+ const kind = event.error?.kind ?? "unclassified";
441
+ errorKinds[kind] = (errorKinds[kind] ?? 0) + 1;
442
+ }
443
+ }
444
+
445
+ // ---------- 窗口 ----------
446
+ let window = { from: null, to: null };
447
+ if (sorted.length > 0) {
448
+ const first = eventTs(sorted[0]);
449
+ const last = eventTs(sorted[sorted.length - 1]);
450
+ window = { from: first !== null ? new Date(first).toISOString() : null, to: last !== null ? new Date(last).toISOString() : null };
451
+ }
452
+
453
+ const requestsTotal = requests.length;
454
+ return {
455
+ schema_version: "1.0",
456
+ tool: { name: "dsh-local-telemetry", version: "0.1.0" },
457
+ generated_at: generated,
458
+ window,
459
+ data_completeness: {
460
+ events: sorted.length,
461
+ requests: requestsTotal,
462
+ model_attempts: modelPairs.pairs.length + modelPairs.unmatched.length,
463
+ tool_calls: toolPairs.pairs.length,
464
+ plugin_calls: pluginPairs.pairs.length,
465
+ note: "percentile method: nearest-rank; durations derived from started/completed span pairs; null = unknown, never fabricated",
466
+ },
467
+ requests: {
468
+ total: requestsTotal,
469
+ success: successCount,
470
+ failed: failedCount,
471
+ cancelled: cancelledCount,
472
+ incomplete: requestsTotal - successCount - failedCount - cancelledCount,
473
+ success_rate: requestsTotal > 0 ? round4(successCount / requestsTotal) : null,
474
+ latency: durationStats(requestDurations),
475
+ queue: durationStats(queueValues),
476
+ ttft: durationStats(ttftValues),
477
+ retries: requests.reduce((acc, r) => acc + r.retries, 0),
478
+ fallbacks: requests.reduce((acc, r) => acc + r.fallbacks, 0),
479
+ },
480
+ tokens,
481
+ cost: {
482
+ amount: costSummary.amount,
483
+ currency: catalog?.currency ?? null,
484
+ priced_events: costSummary.priced_events,
485
+ missing: costSummary.missing,
486
+ source: catalog ? catalogPath : null,
487
+ effective_at: catalog?.effective_at ?? null,
488
+ note: "estimate only; not a bill; null when model/usage/price missing",
489
+ },
490
+ models,
491
+ tools,
492
+ tool_loop_hints: loopHints,
493
+ plugins,
494
+ errors: { kinds: errorKinds },
495
+ };
496
+ }
497
+
498
+ function round4(value) {
499
+ return Math.round(value * 10000) / 10000;
500
+ }
501
+
502
+ /** --group-by:按组键分区后逐组聚合,输出统一行结构。
503
+ * model/tool/plugin 维度只统计携带该维度的事件;profile/day 面向全事件。 */
504
+ export function aggregateGrouped(events, { groupBy, ...opts } = {}) {
505
+ if (!groupBy) return null;
506
+ const groups = new Map();
507
+ for (const event of events) {
508
+ let key;
509
+ switch (groupBy) {
510
+ case "model": key = event.model?.name ?? null; break;
511
+ case "tool": key = event.event === "tool.started" || event.event === "tool.completed" ? event.tool?.name ?? "unknown" : null; break;
512
+ case "plugin": key = event.event.startsWith("plugin.") ? event.plugin?.name ?? "unknown" : null; break;
513
+ case "profile": key = event.session?.profile ?? "unknown"; break;
514
+ case "day": key = typeof event.timestamp === "string" && event.timestamp.length >= 10 ? event.timestamp.slice(0, 10) : "unknown"; break;
515
+ default: return null;
516
+ }
517
+ if (key === null) continue;
518
+ if (!groups.has(key)) groups.set(key, []);
519
+ groups.get(key).push(event);
520
+ }
521
+ const rows = [];
522
+ for (const [key, groupEvents] of groups) {
523
+ const summary = aggregateEvents(groupEvents, opts);
524
+ rows.push({
525
+ group: key,
526
+ events: summary.data_completeness.events,
527
+ requests: summary.requests.total,
528
+ success: summary.requests.success,
529
+ failed: summary.requests.failed,
530
+ cancelled: summary.requests.cancelled,
531
+ p95_ms: summary.requests.latency.p95,
532
+ input_tokens: summary.tokens.input,
533
+ output_tokens: summary.tokens.output,
534
+ cost: summary.cost.amount,
535
+ });
536
+ }
537
+ return rows.sort((a, b) => String(a.group).localeCompare(String(b.group)));
538
+ }
539
+
540
+ /** 慢请求过滤:返回 duration ≥ thresholdMs 的请求 trace_id 集合。 */
541
+ export function slowTraceIds(events, thresholdMs) {
542
+ const result = new Set();
543
+ const { pairs } = pairSpans(events, { startedName: "request.started", endNames: ["request.completed", "request.cancelled"] });
544
+ for (const pair of pairs) {
545
+ const duration = spanDuration(pair);
546
+ if (duration !== null && duration >= thresholdMs) {
547
+ result.add(pair.start.trace_id ?? pair.start.span_id);
548
+ }
549
+ }
550
+ for (const event of events) {
551
+ if ((event.event === "request.completed" || event.event === "request.cancelled") && Number.isInteger(event.duration_ms) && event.duration_ms >= thresholdMs) {
552
+ result.add(event.trace_id ?? event.span_id);
553
+ }
554
+ }
555
+ return result;
556
+ }
557
+
558
+ /** Web UI 请求时间线:逐请求行(脱敏白名单字段,无内容)。 */
559
+ export function listRequestRows(events, { limit = 100 } = {}) {
560
+ const { rows } = aggregateRequestRows(events);
561
+ return rows.slice(0, limit);
562
+ }
563
+
564
+ function aggregateRequestRows(events) {
565
+ const byTrace = new Map();
566
+ for (const event of events) {
567
+ const trace = event.trace_id ?? event.span_id;
568
+ if (!byTrace.has(trace)) byTrace.set(trace, []);
569
+ byTrace.get(trace).push(event);
570
+ }
571
+ const rows = [];
572
+ for (const [trace, traceEvents] of byTrace) {
573
+ const started = traceEvents.find((e) => e.event === "request.started");
574
+ const terminal = traceEvents.find((e) => e.event === "request.completed" || e.event === "request.cancelled");
575
+ if (!started && !terminal) continue;
576
+ const status = terminal ? (terminal.result?.status ?? (terminal.event === "request.cancelled" ? "cancelled" : "success")) : "incomplete";
577
+ let durationMs = null;
578
+ if (Number.isInteger(terminal?.duration_ms)) durationMs = terminal.duration_ms;
579
+ else {
580
+ const s = started ? Date.parse(started.timestamp ?? "") : null;
581
+ const t = terminal ? Date.parse(terminal.timestamp ?? "") : null;
582
+ if (Number.isFinite(s) && Number.isFinite(t)) durationMs = Math.max(0, t - s);
583
+ }
584
+ const attempts = traceEvents.filter((e) => e.event === "model.requested");
585
+ const lastModel = attempts[attempts.length - 1]?.model?.name ?? null;
586
+ const provider = attempts[attempts.length - 1]?.model?.provider ?? null;
587
+ let input = 0;
588
+ let output = 0;
589
+ let cached = 0;
590
+ for (const event of traceEvents) {
591
+ if (event.event !== "model.completed" || !event.usage) continue;
592
+ if (Number.isInteger(event.usage.input_tokens)) input += event.usage.input_tokens;
593
+ if (Number.isInteger(event.usage.output_tokens)) output += event.usage.output_tokens;
594
+ if (Number.isInteger(event.usage.cached_input_tokens)) cached += event.usage.cached_input_tokens;
595
+ }
596
+ const toolCalls = traceEvents.filter((e) => e.event === "tool.started").length;
597
+ rows.push({
598
+ trace_id: trace,
599
+ profile: started?.session?.profile ?? null,
600
+ status,
601
+ started_at: started?.timestamp ?? terminal?.timestamp ?? null,
602
+ duration_ms: durationMs !== null ? Math.round(durationMs) : null,
603
+ model: lastModel,
604
+ provider,
605
+ model_attempts: attempts.length,
606
+ retries: attempts.length > 1 ? attempts.length - 1 : 0,
607
+ tool_calls: toolCalls,
608
+ tokens: { input, output, cached },
609
+ });
610
+ }
611
+ rows.sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
612
+ return { rows };
613
+ }
614
+
615
+ /** --trace:构建 span 树(request 根 → 子 span),孤儿事件挂在 "(unattached)"。 */
616
+ export function buildTraceTree(events) {
617
+ const sorted = [...events].sort((a, b) => (eventTs(a) ?? 0) - (eventTs(b) ?? 0));
618
+ const nodes = new Map(); // span_id -> node
619
+ const roots = [];
620
+ for (const event of sorted) {
621
+ const spanId = event.span_id ?? event.event_id;
622
+ if (!nodes.has(spanId)) {
623
+ nodes.set(spanId, { span_id: spanId, children: [], events: [] });
624
+ }
625
+ nodes.get(spanId).events.push({
626
+ event: event.event,
627
+ timestamp: event.timestamp,
628
+ duration_ms: event.duration_ms ?? null,
629
+ status: event.result?.status ?? null,
630
+ model: event.model?.name ?? null,
631
+ tool: event.tool?.name ?? null,
632
+ plugin: event.plugin?.name ?? null,
633
+ hook: event.plugin?.hook ?? null,
634
+ usage: event.usage ?? null,
635
+ error: event.error ?? null,
636
+ });
637
+ }
638
+ for (const [spanId, node] of nodes) {
639
+ const first = sorted.find((e) => (e.span_id ?? e.event_id) === spanId);
640
+ const parentId = first?.parent_id ?? null;
641
+ if (parentId && nodes.has(parentId)) {
642
+ nodes.get(parentId).children.push(node);
643
+ } else {
644
+ roots.push(node);
645
+ }
646
+ }
647
+ return { roots };
648
+ }
649
+
650
+ /** --trace:扁平时间线 + 树两种视图共用的事件整理。 */
651
+ export function buildTraceView(events) {
652
+ const tree = buildTraceTree(events);
653
+ const timeline = [...events]
654
+ .sort((a, b) => (eventTs(a) ?? 0) - (eventTs(b) ?? 0))
655
+ .map((event) => ({
656
+ event: event.event,
657
+ span_id: event.span_id ?? null,
658
+ parent_id: event.parent_id ?? null,
659
+ timestamp: event.timestamp,
660
+ duration_ms: event.duration_ms ?? null,
661
+ status: event.result?.status ?? null,
662
+ model: event.model?.name ?? null,
663
+ tool: event.tool?.name ?? null,
664
+ plugin: event.plugin?.name ?? null,
665
+ hook: event.plugin?.hook ?? null,
666
+ usage: event.usage ?? null,
667
+ error: event.error?.kind ?? null,
668
+ }));
669
+ const requestEvents = events.filter((e) => e.event === "request.started" || e.event === "request.completed" || e.event === "request.cancelled");
670
+ const rootSpan = requestEvents.find((e) => e.event === "request.started");
671
+ return {
672
+ trace_id: rootSpan?.trace_id ?? events[0]?.trace_id ?? null,
673
+ request_id: rootSpan?.request_id ?? null,
674
+ tree,
675
+ timeline,
676
+ };
677
+ }