@ai-agent-forge/plugin-observability 0.85.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,1442 @@
1
+ /**
2
+ * First-party observability plugin: a per-session bounded event journal over
3
+ * the draft lifecycle catalog plus a deterministic problem-report exporter.
4
+ *
5
+ * Extracted from the host's `src/plugins/observability.ts` (D-075 S4, second
6
+ * B-class batch). The plugin is an ordinary capability-plugin citizen: it
7
+ * subscribes through `api.lifecycle.registerObserve`, persists reports through
8
+ * `api.output` when the host declares the `output-artifacts` feature, and
9
+ * never touches host internals. Every payload that leaves the journal through
10
+ * a problem report is pushed through the host output serializer redaction
11
+ * channel, so internal field keys are replaced at any depth before anything
12
+ * is exported.
13
+ *
14
+ * Event definitions are host-owned: the host's bootstrap lifecycle plugin
15
+ * defines every catalog event before capability plugins load, and this plugin
16
+ * registers observers by `{ id, version }` reference only (see
17
+ * `./event-ids.ts`). A host that has not defined a subscribed event gets a
18
+ * structured registration failure instead of a silent empty subscription.
19
+ */
20
+ import { appendFileSync } from "node:fs";
21
+ import { AGENT_SETTLED_EVENT_ID, AGENT_TASK_SETTLED_EVENT_ID, CATALOG_EVENT_VERSION, INPUT_RECEIVED_EVENT_ID, MODEL_SELECT_EVENT_ID, MODEL_STREAM_EVENT_ID, OPERATION_END_EVENT_ID, PLUGIN_DISPOSE_EVENT_ID, PLUGIN_READY_EVENT_ID, SESSION_AUTO_RETRY_EVENT_ID, SESSION_COMPACTION_COMMIT_EVENT_ID, SESSION_COMPACTION_DECISION_EVENT_ID, SESSION_COMPACTION_FAILED_EVENT_ID, SESSION_CONTEXT_REPLACED_EVENT_ID, SESSION_END_EVENT_ID, SESSION_RELOAD_EVENT_ID, SESSION_START_EVENT_ID, TOOL_CALL_EVENT_ID, TOOL_COMMIT_EVENT_ID, TOOL_EXECUTE_EVENT_ID, TOOL_EXECUTION_START_EVENT_ID, TOOL_GATE_BLOCKED_EVENT_ID, TOOL_RESULT_EVENT_ID, TOOL_RESULT_RAW_EVENT_ID, TURN_END_EVENT_ID, TURN_START_EVENT_ID, UI_PROMPT_END_EVENT_ID, UI_PROMPT_START_EVENT_ID, } from "./event-ids.js";
22
+ import { createOtlpTraceExportSink } from "./otlp-exporter.js";
23
+ import { createHostOutputSerializer, } from "./output-serializer.js";
24
+ export const OBSERVABILITY_PLUGIN_ID = "agent-forge.observability";
25
+ export const DEFAULT_JOURNAL_MAX_ENTRIES = 2000;
26
+ export const DEFAULT_PROBLEM_REPORT_TAIL_SIZE = 50;
27
+ export const PROBLEM_REPORT_OPERATION_ID = "agent-forge.observability/problem-report";
28
+ const REDACTION_POLICY_VERSION = "agent-forge.observability@1";
29
+ /** The builtin memory capability's recall tool; its executions feed the hit rate. */
30
+ export const MEMORY_RECALL_TOOL_NAME = "memory_recall";
31
+ /** Default `maxStringChars` for trace payload snapshots (design §4.3). */
32
+ export const DEFAULT_TRACE_MAX_STRING_CHARS = 16_000;
33
+ /** Default snapshot budget: 384 KiB, strictly below the 512 KiB record budget (design §4.3). */
34
+ export const DEFAULT_TRACE_MAX_SNAPSHOT_BYTES = 393_216;
35
+ /** Hard per-record budget; the write-path guard of last resort (design §4.2/§4.3). */
36
+ export const TRACE_RECORD_MAX_BYTES = 524_288;
37
+ /** Args/result summaries inside `tool.span` data are bounded to ~2 KiB each. */
38
+ export const TRACE_TOOL_SUMMARY_MAX_CHARS = 2_048;
39
+ /**
40
+ * Upper bound per in-flight span-state table (`pendingTurns`, `toolSpans`,
41
+ * `streams`, result-diff pairs). Bounded by design: entries are keyed by
42
+ * in-flight ids and deleted on pairing, but a crash or a lost terminal event
43
+ * would otherwise leak them — past the bound the oldest unpaired entry is
44
+ * evicted FIFO (first key in Map insertion order).
45
+ */
46
+ export const TRACE_SPAN_STATE_MAX_ENTRIES = 256;
47
+ /** Serialized leaves below this size never pay for their own redaction placeholder. */
48
+ const TRACE_SNAPSHOT_MIN_REDACTABLE_BYTES = 64;
49
+ const TRACE_SCHEMA_V1 = "agent-forge/trace.v1";
50
+ const TRACE_SINK_FAILURE_STAGE = "observability.trace.sink-failure";
51
+ function isRecord(value) {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+ function assertPositiveSafeInteger(value, label) {
55
+ if (!Number.isSafeInteger(value) || value < 1)
56
+ throw new Error(`${label} must be a positive safe integer`);
57
+ }
58
+ function assertNonEmptyString(value, label) {
59
+ if (typeof value !== "string" || value.trim() === "")
60
+ throw new Error(`${label} must be a non-empty string`);
61
+ }
62
+ function firstTextContent(data) {
63
+ const content = data.content;
64
+ if (!Array.isArray(content))
65
+ return undefined;
66
+ for (const item of content) {
67
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string")
68
+ return item.text;
69
+ }
70
+ return undefined;
71
+ }
72
+ function extractSessionId(data) {
73
+ if (!isRecord(data) || typeof data.sessionId !== "string" || data.sessionId === "")
74
+ return null;
75
+ return data.sessionId;
76
+ }
77
+ function deriveLevel(stage, data) {
78
+ if (!isRecord(data))
79
+ return "info";
80
+ if (stage === MODEL_STREAM_EVENT_ID)
81
+ return isRecord(data.error) ? "error" : "info";
82
+ if (stage === OPERATION_END_EVENT_ID) {
83
+ if (data.status === "failed")
84
+ return "error";
85
+ return data.status === "cancelled" ? "warn" : "info";
86
+ }
87
+ if (stage === PLUGIN_DISPOSE_EVENT_ID)
88
+ return data.status === "failed" ? "error" : "info";
89
+ if (data.isError === true)
90
+ return "error";
91
+ return "info";
92
+ }
93
+ function buildSummary(stage, data) {
94
+ if (!isRecord(data))
95
+ return stage;
96
+ const isError = data.isError === true;
97
+ switch (stage) {
98
+ case MODEL_STREAM_EVENT_ID:
99
+ return `${String(data.phase)} ${String(data.event)}`;
100
+ case TOOL_CALL_EVENT_ID:
101
+ return `call ${String(data.toolName)}`;
102
+ case TOOL_EXECUTE_EVENT_ID:
103
+ return `execute ${String(data.toolName)}${isError ? " (error)" : ""}`;
104
+ case TOOL_RESULT_RAW_EVENT_ID:
105
+ case TOOL_RESULT_EVENT_ID:
106
+ return `result ${String(data.toolName)}${isError ? " (error)" : ""}`;
107
+ case TOOL_COMMIT_EVENT_ID:
108
+ return `commit ${String(data.toolName)}${isError ? " (error)" : ""}`;
109
+ case OPERATION_END_EVENT_ID:
110
+ return `${String(data.operationKind)} ${String(data.status)}`;
111
+ case PLUGIN_READY_EVENT_ID:
112
+ return `ready ${String(data.pluginId)}`;
113
+ case PLUGIN_DISPOSE_EVENT_ID:
114
+ return `disposed ${String(data.pluginId)} (${String(data.status)})`;
115
+ case SESSION_RELOAD_EVENT_ID:
116
+ return `generation ${String(data.generation)}`;
117
+ case SESSION_CONTEXT_REPLACED_EVENT_ID:
118
+ return `context replaced (${String(data.reason)})`;
119
+ default:
120
+ return stage;
121
+ }
122
+ }
123
+ function extractErrorInfo(entry) {
124
+ const data = entry.data;
125
+ // v8 ignore next -- error 级条目的 data 恒为对象(deriveLevel 已保证)
126
+ if (!isRecord(data))
127
+ return null;
128
+ const phase = typeof data.phase === "string" && data.phase !== "" ? data.phase : undefined;
129
+ if (isRecord(data.error) && typeof data.error.code === "string" && typeof data.error.message === "string") {
130
+ return { code: data.error.code, message: data.error.message, ...(phase === undefined ? {} : { phase }) };
131
+ }
132
+ if (data.status === "failed") {
133
+ const message = typeof data.errorMessage === "string" ? data.errorMessage : entry.summary;
134
+ return { code: "operation.failed", message, ...(phase === undefined ? {} : { phase }) };
135
+ }
136
+ if (data.isError === true) {
137
+ const message = firstTextContent(data) ?? entry.summary;
138
+ return { code: "tool.failed", message, ...(phase === undefined ? {} : { phase }) };
139
+ }
140
+ return null;
141
+ }
142
+ /** Deterministic JSON: sorted object keys, no whitespace, stable scalars. */
143
+ function canonicalJson(value) {
144
+ if (value === null)
145
+ return "null";
146
+ if (typeof value === "string")
147
+ return JSON.stringify(value);
148
+ if (typeof value === "number")
149
+ return Number.isFinite(value) ? String(value) : "null";
150
+ if (typeof value === "boolean")
151
+ return String(value);
152
+ if (Array.isArray(value))
153
+ return `[${value.map(canonicalJson).join(",")}]`;
154
+ // v8 ignore next -- 报告体仅由 JSON-safe 字段构成,不可序列化分支不可达
155
+ if (isRecord(value)) {
156
+ const keys = Object.keys(value)
157
+ .filter((key) => value[key] !== undefined)
158
+ .sort();
159
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
160
+ }
161
+ throw new Error(`Value is not JSON-serializable: ${typeof value}`);
162
+ }
163
+ /** FNV-1a 32-bit over the UTF-8 bytes of the input, rendered as 8 hex chars. */
164
+ export function fnv1a32Hex(input) {
165
+ let hash = 0x811c9dc5;
166
+ for (const byte of new TextEncoder().encode(input)) {
167
+ hash ^= byte;
168
+ hash = Math.imul(hash, 0x01000193);
169
+ }
170
+ return (hash >>> 0).toString(16).padStart(8, "0");
171
+ }
172
+ /**
173
+ * Bounded ring journal with drop-oldest eviction and a replay guard: a
174
+ * lifecycle event id already seen is ignored, so redelivery stays idempotent.
175
+ * The replay window matches the ring bound — evicted events may be re-recorded.
176
+ */
177
+ export function createObservabilityJournal(options = {}) {
178
+ const maxEntries = options.maxEntries ?? DEFAULT_JOURNAL_MAX_ENTRIES;
179
+ assertPositiveSafeInteger(maxEntries, "Journal maxEntries");
180
+ const now = options.now ?? (() => Date.now());
181
+ const entries = [];
182
+ const seenEventIds = new Set();
183
+ const seenOrder = [];
184
+ let sequence = 0;
185
+ let dropped = 0;
186
+ const filterEntries = (filter) => {
187
+ if (filter === undefined)
188
+ return entries;
189
+ if (filter.limit !== undefined)
190
+ assertPositiveSafeInteger(filter.limit, "Query limit");
191
+ const filtered = entries.filter((entry) => (filter.sessionId === undefined || entry.sessionId === filter.sessionId) &&
192
+ (filter.stage === undefined || entry.stage === filter.stage) &&
193
+ (filter.level === undefined || entry.level === filter.level) &&
194
+ (filter.traceId === undefined || entry.traceId === filter.traceId));
195
+ return filter.limit === undefined ? filtered : filtered.slice(-filter.limit);
196
+ };
197
+ return {
198
+ appendEvent(event) {
199
+ assertNonEmptyString(event.id, "Lifecycle event id");
200
+ if (seenEventIds.has(event.id))
201
+ return null;
202
+ seenEventIds.add(event.id);
203
+ seenOrder.push(event.id);
204
+ const entry = Object.freeze({
205
+ sequence: ++sequence,
206
+ at: now(),
207
+ sessionId: extractSessionId(event.data),
208
+ stage: event.type,
209
+ level: deriveLevel(event.type, event.data),
210
+ ...(event.correlationId === undefined ? {} : { traceId: event.correlationId }),
211
+ summary: buildSummary(event.type, event.data),
212
+ ...(event.data === undefined ? {} : { data: event.data }),
213
+ });
214
+ entries.push(entry);
215
+ while (entries.length > maxEntries) {
216
+ entries.shift();
217
+ const evicted = seenOrder.shift();
218
+ // v8 ignore next -- seenOrder 与 entries 同步增删,evicted 不会有 undefined
219
+ if (evicted !== undefined)
220
+ seenEventIds.delete(evicted);
221
+ dropped += 1;
222
+ }
223
+ return entry;
224
+ },
225
+ query: (filter) => [...filterEntries(filter)],
226
+ explainTrace(traceId) {
227
+ assertNonEmptyString(traceId, "Trace id");
228
+ let previous = null;
229
+ const steps = entries
230
+ .filter((entry) => entry.traceId === traceId)
231
+ .map((entry) => {
232
+ const durationMs = previous === null ? null : entry.at - previous;
233
+ previous = entry.at;
234
+ return Object.freeze({ entry, durationMs });
235
+ });
236
+ return { traceId, steps: Object.freeze(steps) };
237
+ },
238
+ getMetrics: (options) => buildMetricsSnapshot(entries, options),
239
+ stats: () => ({ size: entries.length, dropped }),
240
+ };
241
+ }
242
+ /**
243
+ * Linear-interpolated percentile (R-7): position `(n - 1) * ratio` between the
244
+ * sorted order statistics. Returns `null` for an empty sample.
245
+ */
246
+ export function percentile(values, ratio) {
247
+ if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
248
+ throw new Error("Percentile ratio must be a number between 0 and 1");
249
+ }
250
+ if (values.length === 0)
251
+ return null;
252
+ const sorted = [...values].sort((a, b) => a - b);
253
+ const position = (sorted.length - 1) * ratio;
254
+ const lower = Math.floor(position);
255
+ const upper = Math.ceil(position);
256
+ if (lower === upper)
257
+ return sorted[lower];
258
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
259
+ }
260
+ const notSampled = (reason) => ({ status: "not_sampled", reason });
261
+ /** The journal carries no standalone cancel event: `operation.end(cancelled)` is itself the turn's terminal fact. */
262
+ const CANCEL_LATENCY_NOT_SAMPLED = notSampled("journal records no standalone cancel signal event; operation.end(cancelled) is itself the terminal event");
263
+ /** The lifecycle catalog defines no usage/token-bearing event for the journal to subscribe to. */
264
+ const TOKENS_NOT_SAMPLED = notSampled("lifecycle catalog defines no usage/token cost event for the journal to record");
265
+ const isCancelledOperationEnd = (entry) => entry.stage === OPERATION_END_EVENT_ID && isRecord(entry.data) && entry.data.status === "cancelled";
266
+ /**
267
+ * Groups journal entries by their correlationId into turn groups ordered by
268
+ * their last entry's sequence, then keeps only the trailing `windowTurns`
269
+ * groups (all groups when the bound is undefined).
270
+ */
271
+ function selectTurnWindow(entries, windowTurns) {
272
+ const groups = new Map();
273
+ for (const entry of entries) {
274
+ if (entry.traceId === undefined)
275
+ continue;
276
+ const group = groups.get(entry.traceId);
277
+ if (group === undefined)
278
+ groups.set(entry.traceId, [entry]);
279
+ else
280
+ group.push(entry);
281
+ }
282
+ const ordered = [...groups].map(([traceId, group]) => ({
283
+ traceId,
284
+ entries: group,
285
+ lastSequence: group[group.length - 1].sequence,
286
+ }));
287
+ ordered.sort((a, b) => a.lastSequence - b.lastSequence);
288
+ return windowTurns === undefined ? ordered : ordered.slice(-windowTurns);
289
+ }
290
+ /**
291
+ * Aggregates a deterministic product-metrics snapshot from journal entries.
292
+ * Read-only: the entries are consumed as-is, so journal recording behavior is
293
+ * untouched and export-side redaction cannot affect any count.
294
+ *
295
+ * Metric rulings over the lifecycle catalog (v1):
296
+ * - A turn is a correlationId group. It succeeds when it contains a
297
+ * `model.stream` entry (assistant output) and no error-level entry and no
298
+ * `operation.end(cancelled)` terminal; duration is last.at - first.at.
299
+ * - Tool execution is counted once per `tool.execute` entry (the settled
300
+ * invocation; execute/result.raw/result/commit would multiply-count one call).
301
+ * - Cancel latency is not sampled: the catalog has no standalone cancel event
302
+ * among the journal's subscriptions, and `operation.end(cancelled)` is itself
303
+ * the terminal event, so the cancel-to-terminal delay is always zero-by-construction.
304
+ * - Tokens are not sampled: no usage/token event exists in the catalog.
305
+ * - Memory recall hit: a `memory_recall` `tool.execute` whose `isError` is false
306
+ * with non-empty `content`.
307
+ */
308
+ export function buildMetricsSnapshot(entries, options = {}) {
309
+ const windowTurns = options.windowTurns;
310
+ if (windowTurns !== undefined) {
311
+ assertPositiveSafeInteger(windowTurns, "Window turns");
312
+ }
313
+ const groups = selectTurnWindow(entries, windowTurns);
314
+ const windowEntries = groups.flatMap((group) => group.entries);
315
+ const firstAt = windowEntries[0]?.at ?? null;
316
+ const lastAt = windowEntries.at(-1)?.at ?? null;
317
+ const turnDurations = [];
318
+ let successCount = 0;
319
+ for (const group of groups) {
320
+ const first = group.entries[0];
321
+ const last = group.entries[group.entries.length - 1];
322
+ turnDurations.push(last.at - first.at);
323
+ const hasAssistantOutput = group.entries.some((entry) => entry.stage === MODEL_STREAM_EVENT_ID);
324
+ const hasErrorLevel = group.entries.some((entry) => entry.level === "error");
325
+ const hasCancelledTerminal = group.entries.some(isCancelledOperationEnd);
326
+ if (hasAssistantOutput && !hasErrorLevel && !hasCancelledTerminal)
327
+ successCount += 1;
328
+ }
329
+ let toolCalls = 0;
330
+ let toolErrors = 0;
331
+ const errorsByTool = new Map();
332
+ for (const entry of windowEntries) {
333
+ if (entry.stage !== TOOL_EXECUTE_EVENT_ID)
334
+ continue;
335
+ toolCalls += 1;
336
+ if (!isRecord(entry.data) || entry.data.isError !== true)
337
+ continue;
338
+ toolErrors += 1;
339
+ const toolName = typeof entry.data.toolName === "string" ? entry.data.toolName : "unknown";
340
+ errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1);
341
+ }
342
+ const topErrors = [...errorsByTool]
343
+ .map(([toolName, errors]) => ({ toolName, errors }))
344
+ .sort((a, b) => b.errors - a.errors || (a.toolName < b.toolName ? -1 : 1));
345
+ let recallCalls = 0;
346
+ let recallHits = 0;
347
+ for (const entry of windowEntries) {
348
+ if (entry.stage !== TOOL_EXECUTE_EVENT_ID)
349
+ continue;
350
+ if (!isRecord(entry.data) || entry.data.toolName !== MEMORY_RECALL_TOOL_NAME)
351
+ continue;
352
+ recallCalls += 1;
353
+ if (entry.data.isError !== true && Array.isArray(entry.data.content) && entry.data.content.length > 0) {
354
+ recallHits += 1;
355
+ }
356
+ }
357
+ const memoryRecall = recallCalls === 0
358
+ ? notSampled("no memory_recall tool executions in the window")
359
+ : { status: "sampled", calls: recallCalls, hits: recallHits, hitRate: recallHits / recallCalls };
360
+ const cancelLatencyMs = CANCEL_LATENCY_NOT_SAMPLED;
361
+ const tokens = TOKENS_NOT_SAMPLED;
362
+ return Object.freeze({
363
+ version: 1,
364
+ window: Object.freeze({
365
+ windowTurns: windowTurns ?? null,
366
+ sampledTurns: groups.length,
367
+ firstAt,
368
+ lastAt,
369
+ }),
370
+ turns: Object.freeze({
371
+ count: groups.length,
372
+ successCount,
373
+ successRate: groups.length === 0 ? null : successCount / groups.length,
374
+ p50DurationMs: percentile(turnDurations, 0.5),
375
+ p95DurationMs: percentile(turnDurations, 0.95),
376
+ }),
377
+ tools: Object.freeze({
378
+ calls: toolCalls,
379
+ errors: toolErrors,
380
+ errorRate: toolCalls === 0 ? null : toolErrors / toolCalls,
381
+ topErrors: Object.freeze(topErrors),
382
+ }),
383
+ cancelLatencyMs,
384
+ tokens,
385
+ memoryRecall,
386
+ });
387
+ }
388
+ /**
389
+ * Bounded, redacted snapshot of an arbitrary JSON tree (design §4.3):
390
+ * image-like content collapses to `{ redacted: "image", approxBytes }`
391
+ * placeholders, strings over `maxStringChars` truncate with a
392
+ * `…[truncated N chars]` suffix, and — when the serialized snapshot exceeds
393
+ * `maxSnapshotBytes` — string leaves degrade largest-first to
394
+ * `{ redacted: "truncated", approxBytes }` placeholders until it fits.
395
+ * Structure, ordering, roles, and scalars are preserved; the function is pure
396
+ * and total (cycles and non-serializable values become explicit placeholders).
397
+ */
398
+ export function redactProviderPayloadSnapshot(payload, limits) {
399
+ const redacted = redactTree(payload, limits, new Set());
400
+ return enforceSnapshotBudget(redacted, limits.maxSnapshotBytes);
401
+ }
402
+ const truncationSuffix = (originalLength, keptLength) => `…[truncated ${originalLength - keptLength} chars]`;
403
+ /** Best-effort serialized byte size; non-serializable values fall back to their string form. */
404
+ function approxBytesOf(value) {
405
+ try {
406
+ const serialized = JSON.stringify(value);
407
+ if (serialized !== undefined)
408
+ return Buffer.byteLength(serialized, "utf8");
409
+ }
410
+ catch {
411
+ // fall through to the string form below
412
+ }
413
+ return Buffer.byteLength(String(value), "utf8");
414
+ }
415
+ function isImageLikeContent(value) {
416
+ if (value.type === "image" || value.type === "image_url")
417
+ return true;
418
+ const image = value.image;
419
+ if (typeof image === "string" || isRecord(image))
420
+ return true;
421
+ if (typeof value.base64 === "string")
422
+ return true;
423
+ const imageUrl = value.image_url;
424
+ if (typeof imageUrl === "string" || isRecord(imageUrl))
425
+ return true;
426
+ // Google-family adapters inline images as {inlineData:{mimeType,data}} /
427
+ // {inline_data:{mime_type,data}}; both carry raw base64 and must collapse.
428
+ if (isRecord(value.inlineData) || isRecord(value.inline_data))
429
+ return true;
430
+ return false;
431
+ }
432
+ function redactTree(value, limits, seen) {
433
+ if (typeof value === "string") {
434
+ if (value.length > limits.maxStringChars) {
435
+ return `${value.slice(0, limits.maxStringChars)}${truncationSuffix(value.length, limits.maxStringChars)}`;
436
+ }
437
+ return value;
438
+ }
439
+ if (Array.isArray(value)) {
440
+ if (seen.has(value))
441
+ return { redacted: "circular" };
442
+ seen.add(value);
443
+ try {
444
+ return value.map((item) => redactTree(item, limits, seen));
445
+ }
446
+ finally {
447
+ seen.delete(value);
448
+ }
449
+ }
450
+ if (!isRecord(value))
451
+ return value;
452
+ if (seen.has(value))
453
+ return { redacted: "circular" };
454
+ seen.add(value);
455
+ try {
456
+ if (isImageLikeContent(value)) {
457
+ return { redacted: "image", approxBytes: approxBytesOf(value) };
458
+ }
459
+ const out = {};
460
+ for (const [key, entry] of Object.entries(value)) {
461
+ if (entry === undefined)
462
+ continue;
463
+ out[key] = redactTree(entry, limits, seen);
464
+ }
465
+ return out;
466
+ }
467
+ finally {
468
+ seen.delete(value);
469
+ }
470
+ }
471
+ /** Collects string leaves at any depth, keeping parent references for replacement. */
472
+ function collectStringLeaves(value, seen, out) {
473
+ if (typeof value === "string" || !isRecordLikeContainer(value))
474
+ return;
475
+ if (seen.has(value))
476
+ return;
477
+ seen.add(value);
478
+ try {
479
+ const parent = value;
480
+ for (const [key, entry] of Object.entries(parent)) {
481
+ if (typeof entry === "string") {
482
+ const size = Buffer.byteLength(entry, "utf8");
483
+ if (size >= TRACE_SNAPSHOT_MIN_REDACTABLE_BYTES) {
484
+ out.push({ parent, key, size });
485
+ }
486
+ }
487
+ else {
488
+ collectStringLeaves(entry, seen, out);
489
+ }
490
+ }
491
+ }
492
+ finally {
493
+ seen.delete(value);
494
+ }
495
+ }
496
+ function isRecordLikeContainer(value) {
497
+ return value !== null && typeof value === "object";
498
+ }
499
+ /**
500
+ * Degrades the largest string leaves to `{ redacted: "truncated", approxBytes }`
501
+ * placeholders until the snapshot fits `maxSnapshotBytes`. Roots that already
502
+ * fit pass through untouched; residuals fall through to the record-level guard.
503
+ */
504
+ function enforceSnapshotBudget(root, maxSnapshotBytes) {
505
+ let total = approxBytesOf(root);
506
+ if (total <= maxSnapshotBytes)
507
+ return root;
508
+ const leaves = [];
509
+ collectStringLeaves(root, new Set(), leaves);
510
+ // Stable sort (ES2019+): equal sizes keep deterministic walk order.
511
+ leaves.sort((a, b) => b.size - a.size);
512
+ for (const leaf of leaves) {
513
+ if (total <= maxSnapshotBytes)
514
+ break;
515
+ const placeholder = { redacted: "truncated", approxBytes: leaf.size };
516
+ leaf.parent[leaf.key] = placeholder;
517
+ total -= leaf.size - approxBytesOf(placeholder);
518
+ }
519
+ return root;
520
+ }
521
+ /**
522
+ * Last-resort write guard (design §4.3): when a serialized record exceeds the
523
+ * 512 KiB hard budget, its `data` collapses to a record-level truncation
524
+ * marker. Normal traffic never reaches this — the snapshot budget (384 KiB) is
525
+ * strictly below it.
526
+ */
527
+ function enforceRecordBudget(record) {
528
+ const serialized = JSON.stringify(record);
529
+ if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") <= TRACE_RECORD_MAX_BYTES)
530
+ return record;
531
+ return { ...record, data: { truncated: true, approxBytes: approxBytesOf(record.data) } };
532
+ }
533
+ /**
534
+ * Bounded args/result summary for `tool.span` data (≤ ~2 KiB each): values
535
+ * that serialize within the bound stay structured, larger ones keep only a
536
+ * truncated serialization.
537
+ */
538
+ function boundedJsonSummary(value, maxChars) {
539
+ try {
540
+ const serialized = JSON.stringify(value);
541
+ if (serialized === undefined)
542
+ return String(value);
543
+ if (Buffer.byteLength(serialized, "utf8") <= maxChars)
544
+ return value;
545
+ return `${serialized.slice(0, maxChars)}${truncationSuffix(serialized.length, maxChars)}`;
546
+ }
547
+ catch (error) {
548
+ return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`;
549
+ }
550
+ }
551
+ /**
552
+ * Upper bound on retained stack lines for a mirrored Error value; the same
553
+ * bound the host logger applies to a logged cause (core/logger.ts).
554
+ */
555
+ const MIRROR_ERROR_STACK_MAX_LINES = 10;
556
+ /**
557
+ * Error-aware bounded serialization for the `plugin.log` mirror, with the same
558
+ * cause-bounding semantics as the host logger: an `Error` value becomes
559
+ * `{ name, message, stack: first 10 lines }` — plain JSON serialization would
560
+ * collapse it to `{}` because Errors own no enumerable properties — while every
561
+ * other value keeps the existing bounded JSON behavior. Errors nested inside
562
+ * objects/arrays are replaced at any depth, cycle-safe.
563
+ */
564
+ function errorAwareBoundedJsonSummary(value, maxChars) {
565
+ const replaceErrors = (input, seen) => {
566
+ if (input instanceof Error) {
567
+ const stackLines = input.stack?.split("\n");
568
+ const stack = stackLines === undefined ? undefined : stackLines.slice(0, MIRROR_ERROR_STACK_MAX_LINES).join("\n");
569
+ return { name: input.name, message: input.message, ...(stack === undefined ? {} : { stack }) };
570
+ }
571
+ if (Array.isArray(input) || isRecord(input)) {
572
+ if (seen.has(input))
573
+ return { redacted: "circular" };
574
+ seen.add(input);
575
+ try {
576
+ return Array.isArray(input)
577
+ ? input.map((item) => replaceErrors(item, seen))
578
+ : Object.fromEntries(Object.entries(input).map(([key, entry]) => [key, replaceErrors(entry, seen)]));
579
+ }
580
+ finally {
581
+ seen.delete(input);
582
+ }
583
+ }
584
+ return input;
585
+ };
586
+ return boundedJsonSummary(replaceErrors(value, new Set()), maxChars);
587
+ }
588
+ /**
589
+ * First-party delegation tool names whose `tool.span` record gains a
590
+ * structured child-session reference (design §4.6 父子关联行): `subagent_run`'s
591
+ * foreground projection parses `tasks[].sessionId`, `subagent_result`'s a
592
+ * top-level `sessionId`. Third-party delegation tools are deliberately not
593
+ * auto-covered — they can build their own collector over the same public
594
+ * lifecycle subscription face.
595
+ */
596
+ const DELEGATION_TOOL_NAMES = new Set(["subagent_run", "subagent_result"]);
597
+ function firstTextOfContent(content) {
598
+ if (!Array.isArray(content))
599
+ return undefined;
600
+ for (const item of content) {
601
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string")
602
+ return item.text;
603
+ }
604
+ return undefined;
605
+ }
606
+ /**
607
+ * Extracts the structured child-session reference from an untruncated
608
+ * delegation tool result: the projection JSON inside the raw `tool.execute`
609
+ * content is parsed — never the 2 KiB bounded display summary. Shape stays
610
+ * stable across task counts: `childSessionId` is always the first extracted
611
+ * session id and `childSessionCount` the number of session ids extracted
612
+ * (1 for `subagent_result`). Unparsable results, unknown tool names, or
613
+ * projections without a session id yield undefined — the keys stay absent,
614
+ * never fabricated.
615
+ */
616
+ function extractChildSessionReference(toolName, result) {
617
+ if (!DELEGATION_TOOL_NAMES.has(toolName))
618
+ return undefined;
619
+ const text = firstTextOfContent(result);
620
+ if (text === undefined)
621
+ return undefined;
622
+ let parsed;
623
+ try {
624
+ parsed = JSON.parse(text);
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ if (!isRecord(parsed))
630
+ return undefined;
631
+ if (toolName === "subagent_result") {
632
+ if (typeof parsed.sessionId !== "string" || parsed.sessionId === "")
633
+ return undefined;
634
+ return { childSessionId: parsed.sessionId, childSessionCount: 1 };
635
+ }
636
+ const tasks = parsed.tasks;
637
+ if (!Array.isArray(tasks))
638
+ return undefined;
639
+ const sessionIds = [];
640
+ for (const task of tasks) {
641
+ if (isRecord(task) && typeof task.sessionId === "string" && task.sessionId !== "") {
642
+ sessionIds.push(task.sessionId);
643
+ }
644
+ }
645
+ if (sessionIds.length === 0)
646
+ return undefined;
647
+ return { childSessionId: sessionIds[0], childSessionCount: sessionIds.length };
648
+ }
649
+ /**
650
+ * Trace-file sink over `agent-forge/trace.v1` (design §4). Records are
651
+ * appended one JSON line at a time, flushed per write; `seq` restarts at 1
652
+ * whenever the host resolves a different path. Write failures never throw:
653
+ * the first failure of a streak reports through `adapter.onSinkFailure` plus a
654
+ * journal error entry, later consecutive failures only count.
655
+ */
656
+ function createTraceSink(adapter, options, now, reportSinkError, exportSinks) {
657
+ const maxStringChars = options.maxStringChars ?? DEFAULT_TRACE_MAX_STRING_CHARS;
658
+ const maxSnapshotBytes = options.maxSnapshotBytes ?? DEFAULT_TRACE_MAX_SNAPSHOT_BYTES;
659
+ assertPositiveSafeInteger(maxStringChars, "Trace maxStringChars");
660
+ assertPositiveSafeInteger(maxSnapshotBytes, "Trace maxSnapshotBytes");
661
+ const limits = { maxStringChars, maxSnapshotBytes };
662
+ // Telemetry export seam (design §8): host-injected sinks plus the OTLP
663
+ // exporter when configured. Sink failures are isolated per sink and never
664
+ // reach event delivery or the file sink.
665
+ const sinks = [
666
+ ...exportSinks,
667
+ ...(options.otlp === undefined
668
+ ? []
669
+ : [
670
+ createOtlpTraceExportSink({
671
+ ...options.otlp,
672
+ onFailure: (error) => {
673
+ reportSinkError(error);
674
+ },
675
+ }),
676
+ ]),
677
+ ];
678
+ const feedExportSinks = (record) => {
679
+ for (const sink of sinks) {
680
+ try {
681
+ sink.onTraceRecord(record);
682
+ }
683
+ catch {
684
+ // isolated: a broken export sink must not break the file sink
685
+ }
686
+ }
687
+ };
688
+ const flushExportSinks = (reason) => {
689
+ for (const sink of sinks) {
690
+ try {
691
+ void sink.flush(reason).catch(() => { });
692
+ }
693
+ catch {
694
+ // isolated
695
+ }
696
+ }
697
+ };
698
+ let seq = 0;
699
+ let currentPath = null;
700
+ let failing = false;
701
+ const emitRecord = (draft) => {
702
+ const path = adapter.getTraceFilePath();
703
+ if (path === null)
704
+ return;
705
+ try {
706
+ if (path !== currentPath) {
707
+ currentPath = path;
708
+ seq = 0;
709
+ }
710
+ const record = enforceRecordBudget({
711
+ v: 1,
712
+ schema: TRACE_SCHEMA_V1,
713
+ seq: seq + 1,
714
+ t: now(),
715
+ ...draft,
716
+ });
717
+ feedExportSinks(record);
718
+ appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
719
+ seq += 1;
720
+ failing = false;
721
+ }
722
+ catch (error) {
723
+ // Rate-limited failure handling (design §4.4): report the first
724
+ // failure of a streak, stay silent on consecutive ones, never throw.
725
+ if (!failing) {
726
+ failing = true;
727
+ try {
728
+ adapter.onSinkFailure(error);
729
+ }
730
+ catch {
731
+ // sink-failure reporting must never break event delivery
732
+ }
733
+ reportSinkError(error);
734
+ }
735
+ }
736
+ };
737
+ const boundedData = (data) => redactProviderPayloadSnapshot(data, limits);
738
+ const emitFromEvent = (event, draft) => {
739
+ emitRecord({
740
+ ...(event.correlationId === undefined ? {} : { traceId: event.correlationId }),
741
+ ...draft,
742
+ });
743
+ };
744
+ const pendingTurns = new Map();
745
+ const toolSpans = new Map();
746
+ const streams = new Map();
747
+ /** One captured side per toolCallId for the result-diff self-check. */
748
+ const resultDiffPairs = new Map();
749
+ /** toolCallIds whose pairing already emitted a diff record; guards idempotence. */
750
+ const resultDiffSeen = new Map();
751
+ /**
752
+ * Bounded-by-design insertion: past `TRACE_SPAN_STATE_MAX_ENTRIES` the
753
+ * oldest unpaired entry (first key in Map insertion order) is evicted FIFO.
754
+ */
755
+ const setTracked = (map, key, value) => {
756
+ map.set(key, value);
757
+ while (map.size > TRACE_SPAN_STATE_MAX_ENTRIES) {
758
+ const oldest = map.keys().next();
759
+ // v8 ignore next -- 循环条件保证 map 非空,第一个 key 必存在
760
+ if (oldest.done !== true)
761
+ map.delete(oldest.value);
762
+ }
763
+ };
764
+ const turnKey = (event) => {
765
+ if (event.correlationId !== undefined)
766
+ return `c:${event.correlationId}`;
767
+ const data = isRecord(event.data) ? event.data : {};
768
+ const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
769
+ const turnIndex = typeof data.turnIndex === "number" ? data.turnIndex : "";
770
+ return `t:${sessionId}:${turnIndex}`;
771
+ };
772
+ const usageToGenAi = (usage) => {
773
+ if (!isRecord(usage))
774
+ return undefined;
775
+ const mapped = {};
776
+ const fields = [
777
+ ["input", "gen_ai.usage.input_tokens"],
778
+ ["output", "gen_ai.usage.output_tokens"],
779
+ ["cacheRead", "gen_ai.usage.cache_read_tokens"],
780
+ ["cacheWrite", "gen_ai.usage.cache_write_tokens"],
781
+ ["totalTokens", "gen_ai.usage.total_tokens"],
782
+ ];
783
+ for (const [source, target] of fields) {
784
+ const value = usage[source];
785
+ if (typeof value === "number" && Number.isFinite(value))
786
+ mapped[target] = value;
787
+ }
788
+ const cost = usage.cost;
789
+ if (isRecord(cost) && typeof cost.total === "number" && Number.isFinite(cost.total)) {
790
+ mapped["gen_ai.usage.cost"] = cost.total;
791
+ }
792
+ return Object.keys(mapped).length > 0 ? mapped : undefined;
793
+ };
794
+ const emitGenericLifecycle = (event) => {
795
+ emitFromEvent(event, {
796
+ kind: "lifecycle",
797
+ level: deriveLevel(event.type, event.data),
798
+ data: boundedData({ event: event.type, data: event.data }),
799
+ });
800
+ };
801
+ /**
802
+ * Result-diff self-check (design §4.2 `decision` row): captures one side of
803
+ * the `tool.result.raw@1` → `tool.result@1` pairing for a toolCallId. Once
804
+ * both sides arrived, their bounded serializations are compared and a
805
+ * mismatch emits one `decision` record with `{toolCallId, rawBytes,
806
+ * finalBytes}`.
807
+ *
808
+ * Record semantics: "the final output differs from the tool's raw output".
809
+ * Legacy result hooks and image normalization also contribute to a
810
+ * difference, so this is deliberately not a transform attribution. The
811
+ * comparison reuses the 2 KiB bounded summary; both byte sizes refer to
812
+ * those bounded serializations.
813
+ *
814
+ * Idempotence: a toolCallId whose pairing already emitted a `decision`
815
+ * record is remembered (bounded, FIFO-evicted like the other span-state
816
+ * tables); later raw/final deliveries for the same toolCallId are skipped
817
+ * outright, so duplicate events can never trigger a second comparison or a
818
+ * second record.
819
+ */
820
+ const recordResultDiffSide = (side, event) => {
821
+ const data = isRecord(event.data) ? event.data : {};
822
+ const toolCallId = data.toolCallId;
823
+ if (typeof toolCallId !== "string")
824
+ return;
825
+ if (resultDiffSeen.has(toolCallId))
826
+ return;
827
+ const content = side === "raw" ? data.content : isRecord(data.message) ? data.message.content : undefined;
828
+ if (!Array.isArray(content))
829
+ return; // a side that cannot be serialized is never fabricated
830
+ const serialized = JSON.stringify(boundedJsonSummary(content, TRACE_TOOL_SUMMARY_MAX_CHARS));
831
+ if (serialized === undefined)
832
+ return;
833
+ const pair = resultDiffPairs.get(toolCallId) ?? {};
834
+ pair[side] = serialized;
835
+ setTracked(resultDiffPairs, toolCallId, pair);
836
+ if (pair.raw === undefined || pair.final === undefined)
837
+ return;
838
+ resultDiffPairs.delete(toolCallId);
839
+ if (pair.raw === pair.final)
840
+ return;
841
+ setTracked(resultDiffSeen, toolCallId, true);
842
+ emitFromEvent(event, {
843
+ kind: "decision",
844
+ level: "info",
845
+ data: {
846
+ toolCallId,
847
+ rawBytes: Buffer.byteLength(pair.raw, "utf8"),
848
+ finalBytes: Buffer.byteLength(pair.final, "utf8"),
849
+ },
850
+ });
851
+ };
852
+ const handleEvent = (event) => {
853
+ const data = isRecord(event.data) ? event.data : {};
854
+ switch (event.type) {
855
+ case SESSION_START_EVENT_ID: {
856
+ // Session-level parent linkage (design §4.2 session.start row):
857
+ // the session-header facts arrive through the host adapter; an
858
+ // adapter without a getter — or an unset header field — keeps
859
+ // the previous record shape (no parentSessionId/parentTraceId
860
+ // key, never null).
861
+ const parentSessionId = adapter.getParentAgentSessionId?.();
862
+ const parentTraceId = adapter.getParentTraceId?.();
863
+ emitFromEvent(event, {
864
+ kind: "session.start",
865
+ level: "info",
866
+ data: boundedData({
867
+ ...data,
868
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
869
+ ...(parentTraceId === undefined ? {} : { parentTraceId }),
870
+ }),
871
+ });
872
+ return;
873
+ }
874
+ case SESSION_END_EVENT_ID:
875
+ // The session.end fact stays in the trace file (lifecycle record);
876
+ // the export sinks get their final flush beside it (design §8).
877
+ emitGenericLifecycle(event);
878
+ flushExportSinks("session.end");
879
+ return;
880
+ case TURN_START_EVENT_ID:
881
+ setTracked(pendingTurns, turnKey(event), { startedAt: now() });
882
+ return;
883
+ case TURN_END_EVENT_ID: {
884
+ const key = turnKey(event);
885
+ const pending = pendingTurns.get(key);
886
+ if (pending === undefined)
887
+ return; // unpaired span: no record (crash leaves gaps by design)
888
+ pendingTurns.delete(key);
889
+ const endedAt = now();
890
+ emitFromEvent(event, {
891
+ kind: "turn.span",
892
+ level: "info",
893
+ dur: Math.max(0, endedAt - pending.startedAt),
894
+ data: boundedData({
895
+ ...(typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {}),
896
+ ...(typeof data.turnIndex === "number" ? { turnIndex: data.turnIndex } : {}),
897
+ ...(typeof data.messageCount === "number" ? { messageCount: data.messageCount } : {}),
898
+ // 回合级偏离结果(第 4 期):completed 回合省键,从不虚构。
899
+ ...(typeof data.outcome === "string" ? { outcome: data.outcome } : {}),
900
+ }),
901
+ });
902
+ return;
903
+ }
904
+ case TOOL_EXECUTION_START_EVENT_ID: {
905
+ if (typeof data.toolCallId !== "string")
906
+ return;
907
+ const span = toolSpans.get(data.toolCallId) ?? { toolName: "unknown" };
908
+ setTracked(toolSpans, data.toolCallId, {
909
+ ...span,
910
+ toolName: typeof data.toolName === "string" ? data.toolName : span.toolName,
911
+ startedAt: now(),
912
+ });
913
+ return;
914
+ }
915
+ case TOOL_CALL_EVENT_ID:
916
+ case TOOL_EXECUTE_EVENT_ID: {
917
+ // Args/result capture only; the span record is emitted on commit.
918
+ if (typeof data.toolCallId !== "string")
919
+ return;
920
+ const span = toolSpans.get(data.toolCallId) ?? {
921
+ toolName: typeof data.toolName === "string" ? data.toolName : "unknown",
922
+ };
923
+ setTracked(toolSpans, data.toolCallId, {
924
+ ...span,
925
+ ...(data.input !== undefined ? { args: data.input } : {}),
926
+ ...(event.type === TOOL_EXECUTE_EVENT_ID && data.content !== undefined ? { result: data.content } : {}),
927
+ });
928
+ return;
929
+ }
930
+ case TOOL_GATE_BLOCKED_EVENT_ID: {
931
+ // 拦截归因(第 4 期):仅当该 callId 的 span state 已存在时写入
932
+ // blocked 标记;state 缺席即忽略,不照 TOOL_CALL/TOOL_EXECUTION_START
933
+ // 的缺席建 state 先例(防伪造 callId 占用 256 FIFO 淘汰窗口)。
934
+ if (typeof data.toolCallId !== "string")
935
+ return;
936
+ const span = toolSpans.get(data.toolCallId);
937
+ if (span !== undefined) {
938
+ span.blocked = true;
939
+ if (typeof data.reason === "string" && data.reason !== "")
940
+ span.blockReason = data.reason;
941
+ }
942
+ emitGenericLifecycle(event);
943
+ return;
944
+ }
945
+ case TOOL_COMMIT_EVENT_ID: {
946
+ if (typeof data.toolCallId !== "string")
947
+ return;
948
+ const span = toolSpans.get(data.toolCallId);
949
+ if (span === undefined || span.startedAt === undefined)
950
+ return; // unpaired span: no record
951
+ toolSpans.delete(data.toolCallId);
952
+ const isError = data.isError === true;
953
+ const endedAt = now();
954
+ const toolName = typeof data.toolName === "string" ? data.toolName : span.toolName;
955
+ // 父侧结构化子会话引用(第 4 期,design §4.6):仅识别第一方委派工具,
956
+ // 从未截断的 tool.execute content 解析(非 2KiB 展示文本);提取失败省键。
957
+ const childSession = extractChildSessionReference(toolName, span.result);
958
+ emitFromEvent(event, {
959
+ kind: "tool.span",
960
+ level: isError ? "error" : "info",
961
+ dur: Math.max(0, endedAt - span.startedAt),
962
+ data: boundedData({
963
+ callId: data.toolCallId,
964
+ toolName,
965
+ isError,
966
+ // 拦截归因(第 4 期):span state 标记过 blocked 才投影;reason
967
+ // 未标记则省 blockReason 键,正常调用两键都不出现。
968
+ ...(span.blocked === true ? { blocked: true } : {}),
969
+ ...(span.blocked === true && span.blockReason !== undefined ? { blockReason: span.blockReason } : {}),
970
+ ...(childSession === undefined ? {} : { childSessionId: childSession.childSessionId }),
971
+ ...(childSession === undefined ? {} : { childSessionCount: childSession.childSessionCount }),
972
+ ...(span.args !== undefined
973
+ ? { args: boundedJsonSummary(span.args, TRACE_TOOL_SUMMARY_MAX_CHARS) }
974
+ : {}),
975
+ ...(span.result !== undefined
976
+ ? { result: boundedJsonSummary(span.result, TRACE_TOOL_SUMMARY_MAX_CHARS) }
977
+ : {}),
978
+ }),
979
+ });
980
+ return;
981
+ }
982
+ case MODEL_STREAM_EVENT_ID: {
983
+ const streamId = typeof data.streamId === "string" ? data.streamId : null;
984
+ if (streamId === null)
985
+ return;
986
+ const eventName = data.event;
987
+ if (eventName === "start") {
988
+ setTracked(streams, streamId, { startedAt: now() });
989
+ return;
990
+ }
991
+ if (data.terminal !== true) {
992
+ if (typeof eventName === "string" && eventName.endsWith("_delta")) {
993
+ const timing = streams.get(streamId);
994
+ if (timing !== undefined && timing.firstDeltaAt === undefined)
995
+ timing.firstDeltaAt = now();
996
+ }
997
+ return;
998
+ }
999
+ const timing = streams.get(streamId);
1000
+ streams.delete(streamId);
1001
+ const streamEndedAt = now();
1002
+ const partial = isRecord(data.partial) ? data.partial : undefined;
1003
+ const usage = usageToGenAi(partial?.usage);
1004
+ const isErrorEvent = eventName === "error";
1005
+ emitFromEvent(event, {
1006
+ kind: "llm.response",
1007
+ level: isErrorEvent ? "error" : "info",
1008
+ ...(timing?.startedAt !== undefined ? { dur: Math.max(0, streamEndedAt - timing.startedAt) } : {}),
1009
+ data: boundedData({
1010
+ streamId,
1011
+ ...(typeof partial?.stopReason === "string" ? { stopReason: partial.stopReason } : {}),
1012
+ ...(usage !== undefined ? usage : {}),
1013
+ ...(timing?.startedAt !== undefined && timing.firstDeltaAt !== undefined
1014
+ ? { ttftMs: Math.max(0, timing.firstDeltaAt - timing.startedAt) }
1015
+ : {}),
1016
+ ...(isErrorEvent ? { error: boundedData(data.error) } : {}),
1017
+ }),
1018
+ });
1019
+ return;
1020
+ }
1021
+ case SESSION_AUTO_RETRY_EVENT_ID:
1022
+ case SESSION_COMPACTION_DECISION_EVENT_ID:
1023
+ // Retry/compaction decisions: validated event data passes through
1024
+ // the bounded redaction channel verbatim; absent optional fields
1025
+ // (threshold triplet) stay absent — never fabricated.
1026
+ emitFromEvent(event, { kind: "decision", level: "info", data: boundedData(event.data) });
1027
+ return;
1028
+ case SESSION_COMPACTION_FAILED_EVENT_ID:
1029
+ // A genuinely failed compaction is a real failure terminal, not a
1030
+ // decision: records as `error` with the reason/errorMessage facts.
1031
+ emitFromEvent(event, {
1032
+ kind: "error",
1033
+ level: "error",
1034
+ data: boundedData({
1035
+ ...(typeof data.reason === "string" ? { reason: data.reason } : {}),
1036
+ ...(typeof data.errorMessage === "string" ? { errorMessage: data.errorMessage } : {}),
1037
+ }),
1038
+ });
1039
+ return;
1040
+ case SESSION_COMPACTION_COMMIT_EVENT_ID: {
1041
+ // Whitelist projection: summary and other long-text fields stay out
1042
+ // of the trace record on purpose (design §4.2 decision row).
1043
+ emitFromEvent(event, {
1044
+ kind: "decision",
1045
+ level: "info",
1046
+ data: boundedData({
1047
+ ...(typeof data.entryId === "string" ? { entryId: data.entryId } : {}),
1048
+ ...(typeof data.reason === "string" ? { reason: data.reason } : {}),
1049
+ ...(typeof data.tokensBefore === "number" ? { tokensBefore: data.tokensBefore } : {}),
1050
+ ...(data.usage === undefined ? {} : { usage: data.usage }),
1051
+ }),
1052
+ });
1053
+ return;
1054
+ }
1055
+ case TOOL_RESULT_RAW_EVENT_ID:
1056
+ emitGenericLifecycle(event);
1057
+ recordResultDiffSide("raw", event);
1058
+ return;
1059
+ case TOOL_RESULT_EVENT_ID:
1060
+ emitGenericLifecycle(event);
1061
+ recordResultDiffSide("final", event);
1062
+ return;
1063
+ case OPERATION_END_EVENT_ID:
1064
+ case PLUGIN_DISPOSE_EVENT_ID: {
1065
+ if (data.status === "failed") {
1066
+ // Captured error terminal: the `error` kind records the failure fact.
1067
+ emitFromEvent(event, {
1068
+ kind: "error",
1069
+ level: "error",
1070
+ data: boundedData({ source: event.type, ...data }),
1071
+ });
1072
+ return;
1073
+ }
1074
+ emitGenericLifecycle(event);
1075
+ return;
1076
+ }
1077
+ default:
1078
+ emitGenericLifecycle(event);
1079
+ }
1080
+ };
1081
+ return {
1082
+ flush: () => flushExportSinks("dispose"),
1083
+ onEvent(event) {
1084
+ try {
1085
+ handleEvent(event);
1086
+ }
1087
+ catch {
1088
+ // Trace collection must never break event delivery (design §4.4).
1089
+ }
1090
+ },
1091
+ captureProviderPayload(payload, model) {
1092
+ const source = isRecord(payload) ? payload : {};
1093
+ const tools = source.tools;
1094
+ const messages = source.messages;
1095
+ // Both wire shapes are accepted: Anthropic lists tools flat
1096
+ // (`tools[].name`), openai-completions nests them under
1097
+ // `tools[].function.name`; only string names are collected.
1098
+ const toolNames = Array.isArray(tools)
1099
+ ? tools
1100
+ .map((tool) => {
1101
+ if (!isRecord(tool))
1102
+ return null;
1103
+ if (typeof tool.name === "string")
1104
+ return tool.name;
1105
+ const fn = tool.function;
1106
+ return isRecord(fn) && typeof fn.name === "string" ? fn.name : null;
1107
+ })
1108
+ .filter((name) => name !== null)
1109
+ : undefined;
1110
+ const system = source.system;
1111
+ let systemPromptChars;
1112
+ if (typeof system === "string") {
1113
+ systemPromptChars = system.length;
1114
+ }
1115
+ else if (Array.isArray(system)) {
1116
+ let total = 0;
1117
+ let found = false;
1118
+ for (const block of system) {
1119
+ if (isRecord(block) && typeof block.text === "string") {
1120
+ total += block.text.length;
1121
+ found = true;
1122
+ }
1123
+ }
1124
+ if (found)
1125
+ systemPromptChars = total;
1126
+ }
1127
+ if (systemPromptChars === undefined && Array.isArray(messages)) {
1128
+ // openai-completions wire shape: the system prompt rides as
1129
+ // system-role messages instead of a top-level `system` field.
1130
+ let total = 0;
1131
+ let found = false;
1132
+ for (const message of messages) {
1133
+ if (!isRecord(message) || message.role !== "system")
1134
+ continue;
1135
+ const content = message.content;
1136
+ if (typeof content === "string") {
1137
+ total += content.length;
1138
+ found = true;
1139
+ }
1140
+ else if (Array.isArray(content)) {
1141
+ for (const part of content) {
1142
+ if (isRecord(part) && typeof part.text === "string") {
1143
+ total += part.text.length;
1144
+ found = true;
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ if (found)
1150
+ systemPromptChars = total;
1151
+ }
1152
+ const data = {
1153
+ ...(typeof model === "string" ? { "gen_ai.request.model": model } : {}),
1154
+ ...(systemPromptChars === undefined ? {} : { systemPromptChars }),
1155
+ ...(Array.isArray(messages) ? { messageCount: messages.length } : {}),
1156
+ ...(toolNames === undefined ? {} : { toolNames }),
1157
+ payload: redactProviderPayloadSnapshot(payload, limits),
1158
+ };
1159
+ // Emitted immediately: the payload is final before the request is
1160
+ // sent, so "written" means "was about to be sent" even under a crash
1161
+ // (design §4.2 llm.request row). traceId comes from the turn in
1162
+ // flight; an adapter without the getter keeps the phase-1 shape
1163
+ // (no traceId field).
1164
+ const traceId = adapter.getTurnCorrelationId?.();
1165
+ emitRecord({
1166
+ ...(traceId === undefined ? {} : { traceId }),
1167
+ kind: "llm.request",
1168
+ level: "info",
1169
+ data,
1170
+ });
1171
+ },
1172
+ mirrorPluginLog(record) {
1173
+ // Self-recursion guard (design §4.4): never call api.logger on this
1174
+ // path — the host forwards logger output into here, so a logger call
1175
+ // would loop. Trace-append only; emitRecord never throws.
1176
+ const traceId = adapter.getTurnCorrelationId?.();
1177
+ emitRecord({
1178
+ ...(traceId === undefined ? {} : { traceId }),
1179
+ kind: "plugin.log",
1180
+ // debug maps to the envelope's info floor; data.level keeps the
1181
+ // record's own level verbatim.
1182
+ level: record.level === "warn" ? "warn" : record.level === "error" ? "error" : "info",
1183
+ data: {
1184
+ pluginId: record.pluginId,
1185
+ level: record.level,
1186
+ message: record.message,
1187
+ ...(record.fields === undefined
1188
+ ? {}
1189
+ : { fields: errorAwareBoundedJsonSummary(record.fields, TRACE_TOOL_SUMMARY_MAX_CHARS) }),
1190
+ ...(record.cause === undefined
1191
+ ? {}
1192
+ : { cause: errorAwareBoundedJsonSummary(record.cause, TRACE_TOOL_SUMMARY_MAX_CHARS) }),
1193
+ },
1194
+ });
1195
+ },
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Events the journal records; the ids come from the shared lifecycle catalog
1200
+ * (see `./event-ids.ts`). The trace sink consumes the same deliveries but
1201
+ * never changes this set. The host defines these events before capability
1202
+ * plugins load; the plugin only holds the `{ id, version }` reference.
1203
+ */
1204
+ const observedEventIds = [
1205
+ { id: MODEL_STREAM_EVENT_ID, version: CATALOG_EVENT_VERSION },
1206
+ { id: TOOL_CALL_EVENT_ID, version: CATALOG_EVENT_VERSION },
1207
+ { id: TOOL_EXECUTE_EVENT_ID, version: CATALOG_EVENT_VERSION },
1208
+ { id: TOOL_RESULT_RAW_EVENT_ID, version: CATALOG_EVENT_VERSION },
1209
+ { id: TOOL_RESULT_EVENT_ID, version: CATALOG_EVENT_VERSION },
1210
+ { id: TOOL_COMMIT_EVENT_ID, version: CATALOG_EVENT_VERSION },
1211
+ { id: OPERATION_END_EVENT_ID, version: CATALOG_EVENT_VERSION },
1212
+ { id: PLUGIN_READY_EVENT_ID, version: CATALOG_EVENT_VERSION },
1213
+ { id: PLUGIN_DISPOSE_EVENT_ID, version: CATALOG_EVENT_VERSION },
1214
+ { id: SESSION_RELOAD_EVENT_ID, version: CATALOG_EVENT_VERSION },
1215
+ { id: SESSION_CONTEXT_REPLACED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1216
+ ];
1217
+ /**
1218
+ * Trace-only subscriptions (design §4.4): span-pairing, session/agent facts,
1219
+ * and the decision/compaction attribution events the trace sink needs but the
1220
+ * journal must not record, so journal and problem-report behavior stays
1221
+ * byte-identical with or without tracing. Each event id keeps exactly one
1222
+ * registration; these are additional ids, not a parallel subscription
1223
+ * mechanism. Registered only when a trace adapter is injected. The phase-4
1224
+ * additions close the coverage-matrix gaps (design §4.6): session.end@1,
1225
+ * agent.task.settled@1, and the prompt/input boundary events.
1226
+ */
1227
+ const traceOnlyObservedEventIds = [
1228
+ { id: SESSION_START_EVENT_ID, version: CATALOG_EVENT_VERSION },
1229
+ { id: TURN_START_EVENT_ID, version: CATALOG_EVENT_VERSION },
1230
+ { id: TURN_END_EVENT_ID, version: CATALOG_EVENT_VERSION },
1231
+ { id: TOOL_EXECUTION_START_EVENT_ID, version: CATALOG_EVENT_VERSION },
1232
+ { id: MODEL_SELECT_EVENT_ID, version: CATALOG_EVENT_VERSION },
1233
+ { id: AGENT_SETTLED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1234
+ { id: SESSION_AUTO_RETRY_EVENT_ID, version: CATALOG_EVENT_VERSION },
1235
+ { id: SESSION_COMPACTION_DECISION_EVENT_ID, version: CATALOG_EVENT_VERSION },
1236
+ { id: SESSION_COMPACTION_FAILED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1237
+ { id: SESSION_COMPACTION_COMMIT_EVENT_ID, version: CATALOG_EVENT_VERSION },
1238
+ { id: SESSION_END_EVENT_ID, version: CATALOG_EVENT_VERSION },
1239
+ { id: AGENT_TASK_SETTLED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1240
+ // 拦截归因(第 4 期):block 短路观察事实;handleEvent 专门 case 标记
1241
+ // span state 后走 emitGenericLifecycle。
1242
+ { id: TOOL_GATE_BLOCKED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1243
+ // 纯订阅缺口闭合(第 4 期,design §4.6):用户提示边界与输入到达;均经
1244
+ // handleEvent 的 default 分支走 emitGenericLifecycle(kind="lifecycle")。
1245
+ { id: UI_PROMPT_START_EVENT_ID, version: CATALOG_EVENT_VERSION },
1246
+ { id: UI_PROMPT_END_EVENT_ID, version: CATALOG_EVENT_VERSION },
1247
+ { id: INPUT_RECEIVED_EVENT_ID, version: CATALOG_EVENT_VERSION },
1248
+ ];
1249
+ function createRedactionContext() {
1250
+ return { surface: "cli-text", policyVersion: REDACTION_POLICY_VERSION, inspectorAuthorized: false };
1251
+ }
1252
+ function sortedCounts(entries, pick) {
1253
+ const counts = {};
1254
+ for (const entry of entries) {
1255
+ const key = pick(entry);
1256
+ counts[key] = (counts[key] ?? 0) + 1;
1257
+ }
1258
+ const sorted = {};
1259
+ for (const key of Object.keys(counts).sort())
1260
+ sorted[key] = counts[key];
1261
+ return sorted;
1262
+ }
1263
+ /**
1264
+ * Builds a deterministic problem report from the journal. Every payload is
1265
+ * rendered through the host output serializer redaction channel, and the whole
1266
+ * report body goes through a second whole-document pass, so internal field
1267
+ * keys cannot survive at any depth.
1268
+ */
1269
+ export function buildProblemReport(input) {
1270
+ assertNonEmptyString(input.sessionId, "Report sessionId");
1271
+ assertNonEmptyString(input.reason, "Report reason");
1272
+ if (!Number.isSafeInteger(input.tailSize) || input.tailSize < 0) {
1273
+ throw new Error("Report tailSize must be a non-negative safe integer");
1274
+ }
1275
+ const redactionContext = createRedactionContext();
1276
+ const sessionEntries = input.journal.query({ sessionId: input.sessionId });
1277
+ let redactionApplied = false;
1278
+ const projectPayload = (payload) => {
1279
+ const projection = input.serializer.project({ data: payload }, redactionContext);
1280
+ if (projection.redactions !== undefined)
1281
+ redactionApplied = true;
1282
+ return projection.text;
1283
+ };
1284
+ const lastErrorEntry = [...sessionEntries].reverse().find((entry) => entry.level === "error") ?? null;
1285
+ const lastErrorDetail = lastErrorEntry === null ? null : extractErrorInfo(lastErrorEntry);
1286
+ const lastError = lastErrorEntry === null || lastErrorDetail === null
1287
+ ? null
1288
+ : Object.freeze({
1289
+ sequence: lastErrorEntry.sequence,
1290
+ at: lastErrorEntry.at,
1291
+ stage: lastErrorEntry.stage,
1292
+ ...(lastErrorDetail.phase === undefined ? {} : { phase: lastErrorDetail.phase }),
1293
+ traceId: lastErrorEntry.traceId ?? null,
1294
+ code: lastErrorDetail.code,
1295
+ message: lastErrorDetail.message,
1296
+ });
1297
+ const tail = sessionEntries.slice(-input.tailSize).map((entry) => Object.freeze({
1298
+ sequence: entry.sequence,
1299
+ at: entry.at,
1300
+ stage: entry.stage,
1301
+ level: entry.level,
1302
+ traceId: entry.traceId ?? null,
1303
+ summary: entry.summary,
1304
+ ...(entry.data === undefined ? {} : { data: projectPayload(entry.data) }),
1305
+ }));
1306
+ const draft = {
1307
+ version: 1,
1308
+ createdAt: input.now(),
1309
+ reason: input.reason,
1310
+ session: Object.freeze({
1311
+ sessionId: input.sessionId,
1312
+ entryCount: sessionEntries.length,
1313
+ firstSequence: sessionEntries[0]?.sequence ?? null,
1314
+ lastSequence: sessionEntries[sessionEntries.length - 1]?.sequence ?? null,
1315
+ }),
1316
+ lastError,
1317
+ journalTail: tail,
1318
+ counts: Object.freeze({
1319
+ byStage: sortedCounts(sessionEntries, (entry) => entry.stage),
1320
+ byLevel: sortedCounts(sessionEntries, (entry) => entry.level),
1321
+ }),
1322
+ dropped: input.journal.stats().dropped,
1323
+ };
1324
+ // Whole-report redaction pass: the exporter output may only ever carry
1325
+ // allowlisted field names, at any depth, even if a future report field
1326
+ // starts colliding with an internal key.
1327
+ const wholeReportProjection = input.serializer.project(draft, redactionContext);
1328
+ // v8 ignore next -- 报告体字段名不含内部键;二遍脱敏是防未来字段冲突的前瞻防御
1329
+ if (wholeReportProjection.redactions !== undefined)
1330
+ redactionApplied = true;
1331
+ const body = {
1332
+ ...draft,
1333
+ redactionApplied,
1334
+ projection: Object.freeze({ surface: redactionContext.surface, text: wholeReportProjection.text }),
1335
+ };
1336
+ const reportId = fnv1a32Hex(canonicalJson(body));
1337
+ return Object.freeze({ ...body, reportId });
1338
+ }
1339
+ /**
1340
+ * Creates the observability plugin on top of a public capability API.
1341
+ * Returns the journal facade, the deterministic problem-report exporter, and
1342
+ * — when a trace adapter is injected — the trace sink (design §4).
1343
+ */
1344
+ export function createObservabilityPlugin(api, options = {}) {
1345
+ const maxEntries = options.maxEntries ?? DEFAULT_JOURNAL_MAX_ENTRIES;
1346
+ const tailSize = options.tailSize ?? DEFAULT_PROBLEM_REPORT_TAIL_SIZE;
1347
+ assertPositiveSafeInteger(tailSize, "Report tailSize");
1348
+ const now = options.now ?? (() => Date.now());
1349
+ const journal = createObservabilityJournal({ maxEntries, now });
1350
+ const serializer = createHostOutputSerializer();
1351
+ let sinkFailureSequence = 0;
1352
+ const reportSinkError = (error) => {
1353
+ sinkFailureSequence += 1;
1354
+ // Synthetic journal entry (not a catalog event): keeps the failure
1355
+ // visible to problem reports without touching the lifecycle directory.
1356
+ journal.appendEvent({
1357
+ id: `${OBSERVABILITY_PLUGIN_ID}/trace-sink-failure/${sinkFailureSequence}`,
1358
+ deliveryId: `trace-sink-failure-${sinkFailureSequence}`,
1359
+ type: TRACE_SINK_FAILURE_STAGE,
1360
+ version: 1,
1361
+ phase: "committed",
1362
+ source: OBSERVABILITY_PLUGIN_ID,
1363
+ timestamp: now(),
1364
+ // `isError` drives the journal's generic error-level derivation; the
1365
+ // `error` envelope feeds the problem report's last-error extraction.
1366
+ data: {
1367
+ isError: true,
1368
+ error: { code: "trace.sink.failed", message: error instanceof Error ? error.message : String(error) },
1369
+ },
1370
+ });
1371
+ };
1372
+ const traceConfig = options.trace;
1373
+ const trace = traceConfig === undefined
1374
+ ? undefined
1375
+ : createTraceSink(traceConfig.adapter, traceConfig.options ?? {}, now, reportSinkError, traceConfig.exportSinks ?? []);
1376
+ const record = (event) => {
1377
+ journal.appendEvent(event);
1378
+ trace?.onEvent(event);
1379
+ };
1380
+ const registrations = [];
1381
+ for (const binding of observedEventIds) {
1382
+ registrations.push(api.lifecycle.registerObserve(binding.id, record, binding.version));
1383
+ }
1384
+ if (trace !== undefined) {
1385
+ for (const binding of traceOnlyObservedEventIds) {
1386
+ registrations.push(api.lifecycle.registerObserve(binding.id, (event) => trace.onEvent(event), binding.version));
1387
+ }
1388
+ }
1389
+ const exportProblemReport = async (input) => {
1390
+ const report = buildProblemReport({
1391
+ journal,
1392
+ now,
1393
+ tailSize,
1394
+ serializer,
1395
+ sessionId: input.sessionId,
1396
+ reason: input.reason,
1397
+ });
1398
+ // Artifact persistence uses only the public OutputArtifactAPI. When the
1399
+ // host did not declare the `output-artifacts` feature, api.output is
1400
+ // undefined and the report object alone is returned; reaching the host
1401
+ // artifact store through any other path is not allowed here.
1402
+ if (api.output === undefined)
1403
+ return report;
1404
+ const ref = await api.output.put({
1405
+ sessionId: input.sessionId,
1406
+ operationId: PROBLEM_REPORT_OPERATION_ID,
1407
+ kind: "problem-report",
1408
+ mediaType: "application/json",
1409
+ truncated: false,
1410
+ data: new TextEncoder().encode(canonicalJson(report)),
1411
+ });
1412
+ return Object.freeze({ ...report, artifact: Object.freeze({ ...ref }) });
1413
+ };
1414
+ return {
1415
+ id: OBSERVABILITY_PLUGIN_ID,
1416
+ query: (filter) => journal.query(filter),
1417
+ explainTrace: (traceId) => journal.explainTrace(traceId),
1418
+ getMetrics: (options) => journal.getMetrics(options),
1419
+ stats: () => journal.stats(),
1420
+ exportProblemReport,
1421
+ captureProviderPayload: (payload, model) => trace?.captureProviderPayload(payload, model),
1422
+ mirrorPluginLog: (record) => trace?.mirrorPluginLog(record),
1423
+ dispose: () => {
1424
+ trace?.flush();
1425
+ const failures = [];
1426
+ for (const registration of registrations) {
1427
+ try {
1428
+ registration.dispose();
1429
+ }
1430
+ catch (error) {
1431
+ failures.push(error);
1432
+ }
1433
+ }
1434
+ registrations.length = 0;
1435
+ if (failures.length === 1)
1436
+ throw failures[0];
1437
+ if (failures.length > 1)
1438
+ throw new AggregateError(failures, "Observability plugin disposal failed");
1439
+ },
1440
+ };
1441
+ }
1442
+ //# sourceMappingURL=observability.js.map