@alvin0/ai-agent-sdk-observability-node 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,968 @@
1
+ import { a as NodeObservationError, i as NODE_OBSERVATION_ERROR_CODES, n as ensureSafeRoot, r as openExclusiveFile, t as atomicWriteJson } from "./safe-filesystem-CbOPNSoN.mjs";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmod, lstat, readFile, readdir, rename, stat, truncate, unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { deepFreeze, isSpanId, isTraceId } from "@alvin0/ai-agent-sdk-core";
6
+ import { defineObservationExporter } from "@alvin0/ai-agent-sdk-core/observability";
7
+
8
+ //#region src/journal/config.ts
9
+ const JOURNAL_DEFAULTS = Object.freeze({
10
+ maxSegmentBytes: 67108864,
11
+ maxRetainedBytes: 1073741824,
12
+ acknowledgedRetentionMs: 6048e5,
13
+ syncIntervalMs: 100,
14
+ syncRecordCount: 256
15
+ });
16
+ const JOURNAL_LIMITS = Object.freeze({
17
+ recoverySegmentBytes: 68157440,
18
+ cursorBytes: 67108864,
19
+ identifierCharacters: 64
20
+ });
21
+ const JOURNAL_FILES = Object.freeze({
22
+ advancedCursor: "cursor.json",
23
+ runtimeDirectory: "runtime-delivery",
24
+ runtimeCursor: "cursor.json"
25
+ });
26
+ function positiveSafeInteger(value, field) {
27
+ if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${field} must be a positive safe integer`);
28
+ return value;
29
+ }
30
+ function safeSegmentId(value) {
31
+ const normalized = value.replace(/[^A-Za-z0-9_-]/g, "");
32
+ if (normalized.length < 8 || normalized.length > JOURNAL_LIMITS.identifierCharacters) throw new TypeError("journal segmentId must yield 8-64 safe characters");
33
+ return normalized;
34
+ }
35
+
36
+ //#endregion
37
+ //#region src/journal/errors.ts
38
+ function journalFailure(code, message, cause) {
39
+ return new NodeObservationError(NODE_OBSERVATION_ERROR_CODES[code], message, cause === void 0 ? void 0 : { cause });
40
+ }
41
+
42
+ //#endregion
43
+ //#region src/journal/frame.ts
44
+ const EVENT_NAMES = /* @__PURE__ */ new Set([
45
+ "sdk.agent.run",
46
+ "sdk.agent.turn",
47
+ "sdk.model.call",
48
+ "sdk.provider.attempt",
49
+ "sdk.provider.retry.scheduled",
50
+ "sdk.tool.call",
51
+ "sdk.compaction",
52
+ "sdk.hook.call",
53
+ "sdk.user.input.wait",
54
+ "sdk.skill.operation",
55
+ "sdk.memory.operation",
56
+ "sdk.credential.operation",
57
+ "sdk.integration.request",
58
+ "sdk.observer.failure",
59
+ "sdk.exporter.state",
60
+ "sdk.log"
61
+ ]);
62
+ function journalChecksum(payloadJson) {
63
+ return createHash("sha256").update(payloadJson, "utf8").digest("hex");
64
+ }
65
+ function validObservationEvent(value, eventId) {
66
+ if (typeof value !== "object" || value === null) return false;
67
+ try {
68
+ const sequence = Reflect.get(value, "sequence");
69
+ const monotonicMs = Reflect.get(value, "monotonicMs");
70
+ const occurredAt = Reflect.get(value, "occurredAt");
71
+ const resource = Reflect.get(value, "resource");
72
+ const correlation = Reflect.get(value, "correlation");
73
+ const name = Reflect.get(value, "name");
74
+ const optionalCorrelation = [
75
+ "conversationId",
76
+ "turnId",
77
+ "modelCallId",
78
+ "attemptId",
79
+ "toolCallId",
80
+ "providerRequestId",
81
+ "sessionId"
82
+ ].every((key) => {
83
+ const field = Reflect.get(correlation, key);
84
+ return field === void 0 || typeof field === "string" && field.length > 0;
85
+ });
86
+ return Reflect.get(value, "schemaVersion") === 1 && Reflect.get(value, "eventId") === eventId && /^[0-9a-f]{32}$/.test(eventId) && !/^0+$/.test(eventId) && Number.isSafeInteger(sequence) && sequence > 0 && EVENT_NAMES.has(name) && [
87
+ "start",
88
+ "end",
89
+ "point"
90
+ ].includes(Reflect.get(value, "phase")) && [
91
+ "critical",
92
+ "normal",
93
+ "verbose"
94
+ ].includes(Reflect.get(value, "priority")) && typeof occurredAt === "string" && !Number.isNaN(Date.parse(occurredAt)) && new Date(occurredAt).toISOString() === occurredAt && typeof monotonicMs === "number" && Number.isFinite(monotonicMs) && monotonicMs >= 0 && typeof resource === "object" && resource !== null && Reflect.get(resource, "sdkName") === "ai-agent-sdk" && typeof Reflect.get(resource, "sdkVersion") === "string" && Reflect.get(resource, "sdkVersion").length > 0 && [
95
+ "browser",
96
+ "edge",
97
+ "node",
98
+ "unknown"
99
+ ].includes(Reflect.get(resource, "runtime")) && typeof correlation === "object" && correlation !== null && isTraceId(Reflect.get(correlation, "traceId")) && isSpanId(Reflect.get(correlation, "spanId")) && (Reflect.get(correlation, "parentSpanId") === null || isSpanId(Reflect.get(correlation, "parentSpanId"))) && typeof Reflect.get(correlation, "runId") === "string" && Reflect.get(correlation, "runId").length > 0 && optionalCorrelation && typeof Reflect.get(value, "data") === "object" && Reflect.get(value, "data") !== null && !Array.isArray(Reflect.get(value, "data"));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ //#endregion
106
+ //#region src/journal.ts
107
+ function journalLine(event, payloadJson) {
108
+ return `${JSON.stringify({
109
+ schemaVersion: 1,
110
+ eventId: event.eventId,
111
+ payloadJson,
112
+ sha256: journalChecksum(payloadJson)
113
+ })}\n`;
114
+ }
115
+ function dateDay(value) {
116
+ return value.toISOString().slice(0, 10);
117
+ }
118
+ /** Append-only Node journal whose local durability is measured with fdatasync. */
119
+ var JsonlObservationJournalExporter = class {
120
+ id;
121
+ supportedBoundaries;
122
+ options;
123
+ rootPromise;
124
+ current;
125
+ writeTail = Promise.resolve();
126
+ pendingStages = /* @__PURE__ */ new Map();
127
+ batchEvents = /* @__PURE__ */ new Map();
128
+ acknowledged = /* @__PURE__ */ new Set();
129
+ syncTimer;
130
+ unsyncedRecords = 0;
131
+ unsyncedCritical = 0;
132
+ closing = false;
133
+ constructor(options) {
134
+ if (typeof options !== "object" || options === null) throw new TypeError("journal options are required");
135
+ if (![
136
+ "operational",
137
+ "reliable",
138
+ "audit"
139
+ ].includes(options.mode)) throw new TypeError("journal mode is invalid");
140
+ this.id = options.id ?? "journal";
141
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,63})$/.test(this.id)) throw new TypeError("journal id is invalid");
142
+ this.supportedBoundaries = Object.freeze(options.mode === "operational" ? ["none"] : ["local-durable"]);
143
+ this.options = {
144
+ mode: options.mode,
145
+ maxSegmentBytes: positiveSafeInteger(options.maxSegmentBytes ?? JOURNAL_DEFAULTS.maxSegmentBytes, "maxSegmentBytes"),
146
+ maxRetainedBytes: positiveSafeInteger(options.maxRetainedBytes ?? JOURNAL_DEFAULTS.maxRetainedBytes, "maxRetainedBytes"),
147
+ acknowledgedRetentionMs: positiveSafeInteger(options.acknowledgedRetentionMs ?? JOURNAL_DEFAULTS.acknowledgedRetentionMs, "acknowledgedRetentionMs"),
148
+ syncIntervalMs: positiveSafeInteger(options.syncIntervalMs ?? JOURNAL_DEFAULTS.syncIntervalMs, "syncIntervalMs"),
149
+ syncRecordCount: positiveSafeInteger(options.syncRecordCount ?? JOURNAL_DEFAULTS.syncRecordCount, "syncRecordCount"),
150
+ now: options.now ?? (() => /* @__PURE__ */ new Date()),
151
+ segmentId: options.segmentId ?? (() => randomBytes(12).toString("hex"))
152
+ };
153
+ this.rootPromise = this.initialize(options.rootDir);
154
+ this.rootPromise.catch(() => void 0);
155
+ }
156
+ async ready() {
157
+ await this.rootPromise;
158
+ }
159
+ stage(event) {
160
+ if (this.closing) throw journalFailure("io", "observation journal is closed");
161
+ const payloadJson = JSON.stringify(event);
162
+ const existing = this.pendingStages.get(event.eventId);
163
+ if (existing !== void 0) {
164
+ if (existing.payloadJson !== payloadJson) throw journalFailure("corrupt", "duplicate journal eventId has different data");
165
+ return existing.promise;
166
+ }
167
+ const promise = this.enqueueWrite(async () => {
168
+ const root = await this.rootPromise;
169
+ const line = journalLine(event, payloadJson);
170
+ const lineBytes = Buffer.byteLength(line);
171
+ if (lineBytes > JOURNAL_LIMITS.recoverySegmentBytes) throw journalFailure("io", "journal record exceeds the recovery bound");
172
+ await this.ensureCapacity(root, lineBytes, event.priority);
173
+ await this.rotateIfNeeded(root, lineBytes);
174
+ const segment = this.current;
175
+ if (segment === void 0) throw journalFailure("io", "journal segment was not opened");
176
+ await segment.handle.writeFile(line, "utf8");
177
+ segment.bytes += lineBytes;
178
+ segment.eventIds.push(event.eventId);
179
+ this.unsyncedRecords++;
180
+ if (event.priority === "critical") this.unsyncedCritical++;
181
+ if (this.options.mode === "audit") await this.syncCurrent();
182
+ else if (this.options.mode === "reliable") this.scheduleReliableSync();
183
+ });
184
+ this.pendingStages.set(event.eventId, {
185
+ payloadJson,
186
+ promise
187
+ });
188
+ promise.catch(() => void 0);
189
+ return promise;
190
+ }
191
+ async export(batch, signal) {
192
+ if (signal.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("journal export aborted");
193
+ const existingBatch = this.batchEvents.get(batch.batchId);
194
+ if (existingBatch !== void 0) {
195
+ const eventIds = batch.events.map((event) => event.eventId);
196
+ if (eventIds.length !== existingBatch.length || eventIds.some((eventId, index) => eventId !== existingBatch[index])) throw journalFailure("corrupt", "duplicate journal batchId has different events");
197
+ return deepFreeze({
198
+ batchId: batch.batchId,
199
+ accepted: true,
200
+ retryable: false
201
+ });
202
+ }
203
+ await Promise.all(batch.events.map((event) => this.stage(event)));
204
+ if (signal.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("journal export aborted");
205
+ if (this.options.mode !== "operational") await this.enqueueWrite(async () => {
206
+ await this.syncCurrent();
207
+ });
208
+ this.batchEvents.set(batch.batchId, Object.freeze(batch.events.map((event) => event.eventId)));
209
+ for (const event of batch.events) this.pendingStages.delete(event.eventId);
210
+ return deepFreeze({
211
+ batchId: batch.batchId,
212
+ accepted: true,
213
+ retryable: false
214
+ });
215
+ }
216
+ async acknowledgeBatch(batchId) {
217
+ const eventIds = this.batchEvents.get(batchId);
218
+ if (eventIds === void 0) return 0;
219
+ await this.acknowledgeEvents(eventIds);
220
+ this.batchEvents.delete(batchId);
221
+ return eventIds.length;
222
+ }
223
+ async acknowledgeEvents(eventIds) {
224
+ if (!Array.isArray(eventIds) || eventIds.some((eventId) => typeof eventId !== "string" || !/^[0-9a-f]{32}$/.test(eventId) || /^0+$/.test(eventId))) throw new TypeError("journal acknowledgments require valid event IDs");
225
+ await this.enqueueWrite(async () => {
226
+ const root = await this.rootPromise;
227
+ const previous = new Set(this.acknowledged);
228
+ for (const eventId of eventIds) this.acknowledged.add(eventId);
229
+ try {
230
+ await this.persistCursor(root);
231
+ } catch (error) {
232
+ this.acknowledged.clear();
233
+ for (const eventId of previous) this.acknowledged.add(eventId);
234
+ throw error;
235
+ }
236
+ await this.cleanupNow(root);
237
+ });
238
+ }
239
+ async recover() {
240
+ let result;
241
+ await this.enqueueWrite(async () => {
242
+ result = await recoverJournal(await this.rootPromise);
243
+ });
244
+ if (result === void 0) throw journalFailure("io", "journal recovery did not complete");
245
+ return result;
246
+ }
247
+ async cleanup() {
248
+ await this.enqueueWrite(async () => {
249
+ await this.cleanupNow(await this.rootPromise);
250
+ });
251
+ }
252
+ async cleanupNow(root) {
253
+ const recovered = await recoverJournal(root);
254
+ const bySegment = /* @__PURE__ */ new Map();
255
+ for (const record of recovered.records) {
256
+ const records = bySegment.get(record.segment) ?? [];
257
+ records.push(record);
258
+ bySegment.set(record.segment, records);
259
+ }
260
+ const now = this.options.now().getTime();
261
+ const candidates = [];
262
+ for (const [name, records] of bySegment) {
263
+ if (name === this.current?.name) continue;
264
+ const info = await stat(join(root, name));
265
+ candidates.push({
266
+ name,
267
+ bytes: info.size,
268
+ mtimeMs: info.mtimeMs,
269
+ acknowledged: records.every((record) => this.acknowledged.has(record.event.eventId))
270
+ });
271
+ }
272
+ let retained = candidates.reduce((sum, item) => sum + item.bytes, this.current?.bytes ?? 0);
273
+ const deletedAcknowledged = /* @__PURE__ */ new Set();
274
+ for (const candidate of candidates.sort((left, right) => left.mtimeMs - right.mtimeMs)) {
275
+ if (!candidate.acknowledged) continue;
276
+ if (now - candidate.mtimeMs < this.options.acknowledgedRetentionMs && retained <= this.options.maxRetainedBytes) continue;
277
+ await unlink(join(root, candidate.name));
278
+ retained -= candidate.bytes;
279
+ for (const record of bySegment.get(candidate.name) ?? []) deletedAcknowledged.add(record.event.eventId);
280
+ }
281
+ if (deletedAcknowledged.size > 0) {
282
+ for (const eventId of deletedAcknowledged) this.acknowledged.delete(eventId);
283
+ await this.persistCursor(root);
284
+ }
285
+ if (retained > this.options.maxRetainedBytes) throw journalFailure("io", "journal retention cap contains unacknowledged records");
286
+ }
287
+ async stats() {
288
+ let result;
289
+ await this.enqueueWrite(async () => {
290
+ const root = await this.rootPromise;
291
+ const files = (await readdir(root)).filter((name) => name.endsWith(".jsonl"));
292
+ let retainedBytes = 0;
293
+ for (const name of files) retainedBytes += (await stat(join(root, name))).size;
294
+ const recovered = await recoverJournal(root);
295
+ result = deepFreeze({
296
+ segmentCount: files.length,
297
+ retainedBytes,
298
+ unacknowledgedEvents: recovered.records.filter((record) => !this.acknowledged.has(record.event.eventId)).length,
299
+ ...this.current === void 0 ? {} : { currentSegment: this.current.name }
300
+ });
301
+ });
302
+ if (result === void 0) throw journalFailure("io", "journal stats did not complete");
303
+ return result;
304
+ }
305
+ async shutdown(_signal) {
306
+ if (this.closing) return;
307
+ this.closing = true;
308
+ if (this.syncTimer !== void 0) clearTimeout(this.syncTimer);
309
+ await Promise.allSettled([...this.pendingStages.values()].map((stage) => stage.promise));
310
+ await this.enqueueWrite(async () => {
311
+ await this.syncCurrent();
312
+ await this.current?.handle.close();
313
+ this.current = void 0;
314
+ });
315
+ }
316
+ async initialize(rootInput) {
317
+ const root = await ensureSafeRoot(rootInput);
318
+ await this.loadCursor(root);
319
+ await recoverJournal(root);
320
+ await this.openSegment(root);
321
+ return root;
322
+ }
323
+ enqueueWrite(operation) {
324
+ const result = this.writeTail.then(operation);
325
+ this.writeTail = result.catch(() => void 0);
326
+ return result;
327
+ }
328
+ async openSegment(root) {
329
+ const day = dateDay(this.options.now());
330
+ const id = safeSegmentId(this.options.segmentId());
331
+ const name = `${day}-${process.pid}-${id}.jsonl`;
332
+ const handle = await openExclusiveFile(root, name);
333
+ this.current = {
334
+ name,
335
+ day,
336
+ handle,
337
+ bytes: 0,
338
+ eventIds: []
339
+ };
340
+ }
341
+ async rotateIfNeeded(root, incomingBytes) {
342
+ const current = this.current;
343
+ if (current === void 0) {
344
+ await this.openSegment(root);
345
+ return;
346
+ }
347
+ const day = dateDay(this.options.now());
348
+ if (current.day === day && (current.bytes === 0 || current.bytes + incomingBytes <= this.options.maxSegmentBytes)) return;
349
+ await this.syncCurrent();
350
+ await current.handle.close();
351
+ this.current = void 0;
352
+ await this.openSegment(root);
353
+ }
354
+ scheduleReliableSync() {
355
+ if (this.unsyncedCritical >= this.options.syncRecordCount) {
356
+ if (this.syncTimer !== void 0) clearTimeout(this.syncTimer);
357
+ this.syncTimer = void 0;
358
+ this.enqueueWrite(async () => {
359
+ await this.syncCurrent();
360
+ }).catch(() => void 0);
361
+ return;
362
+ }
363
+ if (this.syncTimer !== void 0) return;
364
+ this.syncTimer = setTimeout(() => {
365
+ this.syncTimer = void 0;
366
+ this.enqueueWrite(async () => {
367
+ await this.syncCurrent();
368
+ }).catch(() => void 0);
369
+ }, this.options.syncIntervalMs);
370
+ this.syncTimer.unref?.();
371
+ }
372
+ async syncCurrent() {
373
+ if (this.current === void 0 || this.unsyncedRecords === 0) return;
374
+ await this.current.handle.datasync();
375
+ this.unsyncedRecords = 0;
376
+ this.unsyncedCritical = 0;
377
+ }
378
+ async ensureCapacity(root, incomingBytes, priority) {
379
+ const files = (await readdir(root)).filter((name) => name.endsWith(".jsonl"));
380
+ let total = 0;
381
+ for (const name of files) total += (await stat(join(root, name))).size;
382
+ if (total + incomingBytes <= this.options.maxRetainedBytes) return;
383
+ await this.cleanupNow(root);
384
+ total = 0;
385
+ for (const name of files) {
386
+ const path = join(root, name);
387
+ total += await stat(path).then((value) => value.size, () => 0);
388
+ }
389
+ if (total + incomingBytes > this.options.maxRetainedBytes) throw journalFailure("io", priority === "critical" ? "journal capacity contains unacknowledged critical records" : "journal capacity is exhausted");
390
+ }
391
+ async loadCursor(root) {
392
+ let raw;
393
+ try {
394
+ const path = join(root, JOURNAL_FILES.advancedCursor);
395
+ const info = await lstat(path);
396
+ if (!info.isFile() || info.isSymbolicLink() || info.size > JOURNAL_LIMITS.cursorBytes) throw new Error("unsafe cursor");
397
+ raw = await readFile(path, "utf8");
398
+ } catch (error) {
399
+ if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "ENOENT") return;
400
+ throw journalFailure("io", "journal cursor read failed", error);
401
+ }
402
+ try {
403
+ const value = JSON.parse(raw);
404
+ if (value.schemaVersion !== 1 || !Array.isArray(value.acknowledgedEventIds) || value.acknowledgedEventIds.some((id) => typeof id !== "string" || !/^[0-9a-f]{32}$/.test(id))) throw new Error("invalid cursor");
405
+ for (const id of value.acknowledgedEventIds) this.acknowledged.add(id);
406
+ } catch (error) {
407
+ throw journalFailure("corrupt", "journal cursor is corrupt", error);
408
+ }
409
+ }
410
+ async persistCursor(root) {
411
+ const value = {
412
+ schemaVersion: 1,
413
+ acknowledgedEventIds: [...this.acknowledged].sort()
414
+ };
415
+ if (Buffer.byteLength(JSON.stringify(value)) > JOURNAL_LIMITS.cursorBytes) throw journalFailure("io", "journal cursor exceeds its persistence bound");
416
+ await atomicWriteJson(root, JOURNAL_FILES.advancedCursor, value);
417
+ }
418
+ };
419
+ async function recoverJournal(rootInput) {
420
+ const root = await ensureSafeRoot(rootInput);
421
+ const names = (await readdir(root)).filter((name) => name.endsWith(".jsonl")).sort();
422
+ const records = [];
423
+ const quarantinedSegments = [];
424
+ const truncatedSegments = [];
425
+ const eventIds = /* @__PURE__ */ new Set();
426
+ for (const name of names) {
427
+ const path = join(root, name);
428
+ const info = await lstat(path);
429
+ if (!info.isFile() || info.isSymbolicLink()) throw journalFailure("io", "journal segment is not a regular file");
430
+ if (info.size > JOURNAL_LIMITS.recoverySegmentBytes) throw journalFailure("corrupt", "journal segment exceeds recovery bound");
431
+ await chmod(path, 384);
432
+ let text = await readFile(path, "utf8");
433
+ if (text.length > 0 && !text.endsWith("\n")) {
434
+ const boundary = text.lastIndexOf("\n") + 1;
435
+ await truncate(path, Buffer.byteLength(text.slice(0, boundary)));
436
+ text = text.slice(0, boundary);
437
+ truncatedSegments.push(name);
438
+ }
439
+ const lines = text.length === 0 ? [] : text.slice(0, -1).split("\n");
440
+ const segmentEventIds = [];
441
+ for (let index = 0; index < lines.length; index++) {
442
+ const line = lines[index] ?? "";
443
+ try {
444
+ const envelope = JSON.parse(line);
445
+ if (envelope.schemaVersion !== 1 || typeof envelope.eventId !== "string" || typeof envelope.payloadJson !== "string" || typeof envelope.sha256 !== "string" || envelope.sha256 !== journalChecksum(envelope.payloadJson)) throw new Error("invalid frame");
446
+ const event = JSON.parse(envelope.payloadJson);
447
+ if (!validObservationEvent(event, envelope.eventId) || eventIds.has(envelope.eventId)) throw new Error("invalid event");
448
+ eventIds.add(envelope.eventId);
449
+ segmentEventIds.push(envelope.eventId);
450
+ records.push(deepFreeze({
451
+ segment: name,
452
+ line: index + 1,
453
+ event,
454
+ payloadJson: envelope.payloadJson
455
+ }));
456
+ } catch (error) {
457
+ if (index === lines.length - 1) {
458
+ const quarantine = `${name}.corrupt-${Date.now()}`;
459
+ await rename(path, join(root, quarantine));
460
+ quarantinedSegments.push(quarantine);
461
+ for (let recordIndex = records.length - 1; recordIndex >= 0; recordIndex--) if (records[recordIndex]?.segment === name) records.splice(recordIndex, 1);
462
+ for (const eventId of segmentEventIds) eventIds.delete(eventId);
463
+ break;
464
+ }
465
+ throw journalFailure("corrupt", `journal segment ${name} has mid-file corruption`, error);
466
+ }
467
+ }
468
+ }
469
+ return deepFreeze({
470
+ records,
471
+ quarantinedSegments,
472
+ truncatedSegments
473
+ });
474
+ }
475
+
476
+ //#endregion
477
+ //#region src/journal/runtime-options.ts
478
+ function captureRuntimeJournalOptions(options) {
479
+ if (typeof options !== "object" || options === null) throw new TypeError("journal options are required");
480
+ if (![
481
+ "operational",
482
+ "reliable",
483
+ "audit"
484
+ ].includes(options.mode)) throw new TypeError("journal mode is invalid");
485
+ const id = options.id ?? "journal";
486
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,63})$/.test(id)) throw new TypeError("journal id is invalid");
487
+ if (typeof options.rootDir !== "string" || options.rootDir.trim().length === 0) throw new TypeError("observation journal rootDir must be explicit and non-empty");
488
+ if (options.now !== void 0 && typeof options.now !== "function") throw new TypeError("journal now must be a function");
489
+ if (options.segmentId !== void 0 && typeof options.segmentId !== "function") throw new TypeError("journal segmentId must be a function");
490
+ const supportedBoundaries = Object.freeze(options.mode === "operational" ? ["none"] : ["local-durable"]);
491
+ return Object.freeze({
492
+ id,
493
+ rootDir: options.rootDir,
494
+ mode: options.mode,
495
+ maxSegmentBytes: positiveSafeInteger(options.maxSegmentBytes ?? JOURNAL_DEFAULTS.maxSegmentBytes, "maxSegmentBytes"),
496
+ maxRetainedBytes: positiveSafeInteger(options.maxRetainedBytes ?? JOURNAL_DEFAULTS.maxRetainedBytes, "maxRetainedBytes"),
497
+ acknowledgedRetentionMs: positiveSafeInteger(options.acknowledgedRetentionMs ?? JOURNAL_DEFAULTS.acknowledgedRetentionMs, "acknowledgedRetentionMs"),
498
+ syncIntervalMs: positiveSafeInteger(options.syncIntervalMs ?? JOURNAL_DEFAULTS.syncIntervalMs, "syncIntervalMs"),
499
+ syncRecordCount: positiveSafeInteger(options.syncRecordCount ?? JOURNAL_DEFAULTS.syncRecordCount, "syncRecordCount"),
500
+ now: options.now ?? (() => /* @__PURE__ */ new Date()),
501
+ segmentId: options.segmentId ?? (() => randomBytes(12).toString("hex")),
502
+ supportedBoundaries
503
+ });
504
+ }
505
+
506
+ //#endregion
507
+ //#region src/journal/runtime-frame.ts
508
+ function runtimeItemIdentity(item) {
509
+ const kind = "kind" in item && item.kind === "run-terminal-record" ? "run-terminal-record" : "event";
510
+ const id = kind === "event" ? item.eventId : item.runId;
511
+ return {
512
+ kind,
513
+ id,
514
+ key: `${kind}:${id}`
515
+ };
516
+ }
517
+ function runtimeJournalLine(item, payloadJson) {
518
+ const identity = runtimeItemIdentity(item);
519
+ return `${JSON.stringify({
520
+ schemaVersion: 1,
521
+ itemKind: identity.kind,
522
+ itemId: identity.id,
523
+ payloadJson,
524
+ sha256: journalChecksum(payloadJson)
525
+ })}\n`;
526
+ }
527
+ function parseRuntimeJournalLine(line, segment, lineNumber) {
528
+ const envelope = JSON.parse(line);
529
+ if (envelope.schemaVersion !== 1 || envelope.itemKind !== "event" && envelope.itemKind !== "run-terminal-record" || typeof envelope.itemId !== "string" || typeof envelope.payloadJson !== "string" || typeof envelope.sha256 !== "string" || envelope.sha256 !== journalChecksum(envelope.payloadJson)) throw new Error("invalid frame");
530
+ const parsed = JSON.parse(envelope.payloadJson);
531
+ let item;
532
+ if (envelope.itemKind === "event") {
533
+ if (!validObservationEvent(parsed, envelope.itemId)) throw new Error("invalid journal item");
534
+ item = parsed;
535
+ } else {
536
+ if (!validTerminalRecord(parsed, envelope.itemId)) throw new Error("invalid journal item");
537
+ item = parsed;
538
+ }
539
+ return {
540
+ segment,
541
+ line: lineNumber,
542
+ kind: envelope.itemKind,
543
+ id: envelope.itemId,
544
+ key: `${envelope.itemKind}:${envelope.itemId}`,
545
+ item,
546
+ payloadJson: envelope.payloadJson
547
+ };
548
+ }
549
+ function validTerminalRecord(value, runId) {
550
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
551
+ try {
552
+ const startedAt = Reflect.get(value, "startedAt");
553
+ const endedAt = Reflect.get(value, "endedAt");
554
+ const durationMs = Reflect.get(value, "durationMs");
555
+ return Reflect.get(value, "kind") === "run-terminal-record" && Reflect.get(value, "runId") === runId && runId.length > 0 && runId.length <= 128 && isTraceId(Reflect.get(value, "traceId")) && validIsoDate(startedAt) && validIsoDate(endedAt) && typeof durationMs === "number" && Number.isFinite(durationMs) && durationMs >= 0 && [
556
+ "success",
557
+ "error",
558
+ "aborted",
559
+ "rejected",
560
+ "unknown"
561
+ ].includes(Reflect.get(value, "status")) && objectRecord(Reflect.get(value, "usage")) && Array.isArray(Reflect.get(value, "modelCalls")) && Array.isArray(Reflect.get(value, "toolSourceSnapshots")) && objectRecord(Reflect.get(value, "operationCounts")) && Array.isArray(Reflect.get(value, "errors")) && !Object.prototype.hasOwnProperty.call(value, "delivery");
562
+ } catch {
563
+ return false;
564
+ }
565
+ }
566
+ function validIsoDate(value) {
567
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
568
+ }
569
+ function objectRecord(value) {
570
+ return typeof value === "object" && value !== null && !Array.isArray(value);
571
+ }
572
+
573
+ //#endregion
574
+ //#region src/journal/runtime-store.ts
575
+ var RuntimeJsonlJournal = class {
576
+ options;
577
+ root;
578
+ current;
579
+ writeTail = Promise.resolve();
580
+ payloads = /* @__PURE__ */ new Map();
581
+ pending = /* @__PURE__ */ new Map();
582
+ batches = /* @__PURE__ */ new Map();
583
+ accepted = /* @__PURE__ */ new Set();
584
+ syncTimer;
585
+ unsyncedRecords = 0;
586
+ unsyncedCritical = 0;
587
+ failed;
588
+ closing = false;
589
+ constructor(options) {
590
+ this.options = options;
591
+ }
592
+ async ready(signal) {
593
+ abortIfRequested(signal);
594
+ if (this.root !== void 0) return;
595
+ const parent = await ensureSafeRoot(this.options.rootDir);
596
+ abortIfRequested(signal);
597
+ const root = await ensureSafeRoot(join(parent, JOURNAL_FILES.runtimeDirectory));
598
+ abortIfRequested(signal);
599
+ await this.loadCursor(root);
600
+ const recovered = await recoverRuntimeJournal(root);
601
+ for (const record of recovered.records) {
602
+ const previous = this.payloads.get(record.key);
603
+ if (previous !== void 0 && previous !== record.payloadJson) throw journalFailure("corrupt", "duplicate runtime journal item has different data");
604
+ this.payloads.set(record.key, record.payloadJson);
605
+ }
606
+ for (const key of [...this.accepted]) if (!this.payloads.has(key)) this.accepted.delete(key);
607
+ abortIfRequested(signal);
608
+ await this.openSegment(root);
609
+ if (signal.aborted) {
610
+ await this.current?.handle.close().catch(() => void 0);
611
+ this.current = void 0;
612
+ abortIfRequested(signal);
613
+ }
614
+ this.root = root;
615
+ }
616
+ stage(item) {
617
+ this.ensureAvailable();
618
+ const identity = runtimeItemIdentity(item);
619
+ const payloadJson = JSON.stringify(item);
620
+ const persisted = this.payloads.get(identity.key);
621
+ if (persisted !== void 0) {
622
+ if (persisted !== payloadJson) throw journalFailure("corrupt", "duplicate runtime journal item has different data");
623
+ return Promise.resolve();
624
+ }
625
+ const existing = this.pending.get(identity.key);
626
+ if (existing !== void 0) {
627
+ if (existing.payloadJson !== payloadJson) throw journalFailure("corrupt", "duplicate runtime journal item has different data");
628
+ return existing.promise;
629
+ }
630
+ const promise = this.enqueue(async () => {
631
+ const root = this.requiredRoot();
632
+ const line = runtimeJournalLine(item, payloadJson);
633
+ const lineBytes = Buffer.byteLength(line);
634
+ if (lineBytes > JOURNAL_LIMITS.recoverySegmentBytes) throw journalFailure("io", "runtime journal record exceeds the recovery bound");
635
+ const priority = identity.kind === "run-terminal-record" ? "critical" : item.priority;
636
+ await this.ensureCapacity(root, lineBytes, priority);
637
+ await this.rotateIfNeeded(root, lineBytes);
638
+ if (this.current === void 0) throw journalFailure("io", "runtime journal segment was not opened");
639
+ await this.current.handle.writeFile(line, "utf8");
640
+ this.current.bytes += lineBytes;
641
+ this.payloads.set(identity.key, payloadJson);
642
+ this.unsyncedRecords++;
643
+ if (priority === "critical") this.unsyncedCritical++;
644
+ if (this.options.mode === "audit") await this.syncCurrent();
645
+ else if (this.options.mode === "reliable") this.scheduleReliableSync();
646
+ });
647
+ this.pending.set(identity.key, {
648
+ payloadJson,
649
+ promise
650
+ });
651
+ promise.then(() => {
652
+ this.pending.delete(identity.key);
653
+ }, (error) => {
654
+ this.failed = error;
655
+ });
656
+ return promise;
657
+ }
658
+ async export(batch, signal) {
659
+ this.ensureAvailable();
660
+ abortIfRequested(signal);
661
+ const items = [...batch.events, ...batch.runRecords];
662
+ const keys = items.map((item) => runtimeItemIdentity(item).key);
663
+ const previous = this.batches.get(batch.id);
664
+ if (previous !== void 0) {
665
+ if (!sameList(previous, keys)) throw journalFailure("corrupt", "duplicate runtime journal batch has different items");
666
+ return deliveryAck(batch);
667
+ }
668
+ await Promise.all(items.map((item) => this.stage(item)));
669
+ abortIfRequested(signal);
670
+ await this.enqueue(async () => {
671
+ await this.syncCurrent();
672
+ const before = new Set(this.accepted);
673
+ for (const key of keys) this.accepted.add(key);
674
+ try {
675
+ await this.persistCursor(this.requiredRoot());
676
+ await this.cleanupNow(this.requiredRoot());
677
+ } catch (error) {
678
+ this.accepted.clear();
679
+ for (const key of before) this.accepted.add(key);
680
+ throw error;
681
+ }
682
+ });
683
+ abortIfRequested(signal);
684
+ this.batches.set(batch.id, Object.freeze(keys));
685
+ return deliveryAck(batch);
686
+ }
687
+ async shutdown(signal) {
688
+ if (this.closing) return;
689
+ this.closing = true;
690
+ if (this.syncTimer !== void 0) clearTimeout(this.syncTimer);
691
+ await Promise.allSettled([...this.pending.values()].map((value) => value.promise));
692
+ abortIfRequested(signal);
693
+ await this.enqueue(async () => {
694
+ await this.syncCurrent();
695
+ await this.current?.handle.close();
696
+ this.current = void 0;
697
+ });
698
+ }
699
+ ensureAvailable() {
700
+ if (this.root === void 0) throw journalFailure("io", "runtime observation journal is not ready");
701
+ if (this.closing) throw journalFailure("io", "runtime observation journal is closed");
702
+ if (this.failed !== void 0) throw journalFailure("io", "runtime observation journal has failed", this.failed);
703
+ }
704
+ requiredRoot() {
705
+ if (this.root === void 0) throw journalFailure("io", "runtime observation journal is not ready");
706
+ return this.root;
707
+ }
708
+ enqueue(operation) {
709
+ const result = this.writeTail.then(operation);
710
+ this.writeTail = result.catch(() => void 0);
711
+ return result;
712
+ }
713
+ async openSegment(root) {
714
+ const day = validNow(this.options.now()).toISOString().slice(0, 10);
715
+ const id = safeSegmentId(this.options.segmentId());
716
+ const name = `${day}-${process.pid}-${id}-${randomBytes(4).toString("hex")}.jsonl`;
717
+ this.current = {
718
+ name,
719
+ day,
720
+ handle: await openExclusiveFile(root, name),
721
+ bytes: 0
722
+ };
723
+ }
724
+ async rotateIfNeeded(root, incomingBytes) {
725
+ const current = this.current;
726
+ if (current === void 0) return this.openSegment(root);
727
+ const day = validNow(this.options.now()).toISOString().slice(0, 10);
728
+ if (current.day === day && (current.bytes === 0 || current.bytes + incomingBytes <= this.options.maxSegmentBytes)) return;
729
+ await this.syncCurrent();
730
+ await current.handle.close();
731
+ this.current = void 0;
732
+ await this.openSegment(root);
733
+ }
734
+ scheduleReliableSync() {
735
+ if (this.unsyncedCritical >= this.options.syncRecordCount) {
736
+ if (this.syncTimer !== void 0) clearTimeout(this.syncTimer);
737
+ this.syncTimer = void 0;
738
+ this.enqueue(() => this.syncCurrent()).catch((error) => {
739
+ this.failed = error;
740
+ });
741
+ return;
742
+ }
743
+ if (this.syncTimer !== void 0) return;
744
+ this.syncTimer = setTimeout(() => {
745
+ this.syncTimer = void 0;
746
+ this.enqueue(() => this.syncCurrent()).catch((error) => {
747
+ this.failed = error;
748
+ });
749
+ }, this.options.syncIntervalMs);
750
+ this.syncTimer.unref?.();
751
+ }
752
+ async syncCurrent() {
753
+ if (this.current === void 0 || this.unsyncedRecords === 0) return;
754
+ await this.current.handle.datasync();
755
+ this.unsyncedRecords = 0;
756
+ this.unsyncedCritical = 0;
757
+ }
758
+ async ensureCapacity(root, incomingBytes, priority) {
759
+ let total = await retainedBytes(root);
760
+ if (total + incomingBytes <= this.options.maxRetainedBytes) return;
761
+ await this.cleanupNow(root);
762
+ total = await retainedBytes(root);
763
+ if (total + incomingBytes > this.options.maxRetainedBytes) throw journalFailure("io", priority === "critical" ? "runtime journal capacity contains unacknowledged critical records" : "runtime journal capacity is exhausted");
764
+ }
765
+ async cleanupNow(root) {
766
+ const recovered = await recoverRuntimeJournal(root);
767
+ const bySegment = /* @__PURE__ */ new Map();
768
+ for (const record of recovered.records) {
769
+ const records = bySegment.get(record.segment) ?? [];
770
+ records.push(record);
771
+ bySegment.set(record.segment, records);
772
+ }
773
+ const candidates = [];
774
+ for (const [name, records] of bySegment) {
775
+ if (name === this.current?.name) continue;
776
+ const info = await stat(join(root, name));
777
+ candidates.push({
778
+ name,
779
+ bytes: info.size,
780
+ mtimeMs: info.mtimeMs,
781
+ accepted: records.every((record) => this.accepted.has(record.key))
782
+ });
783
+ }
784
+ let retained = candidates.reduce((sum, value) => sum + value.bytes, this.current?.bytes ?? 0);
785
+ let cursorChanged = false;
786
+ const now = validNow(this.options.now()).getTime();
787
+ for (const candidate of candidates.sort((left, right) => left.mtimeMs - right.mtimeMs)) {
788
+ if (!candidate.accepted) continue;
789
+ if (now - candidate.mtimeMs < this.options.acknowledgedRetentionMs && retained <= this.options.maxRetainedBytes) continue;
790
+ await unlink(join(root, candidate.name));
791
+ retained -= candidate.bytes;
792
+ for (const record of bySegment.get(candidate.name) ?? []) {
793
+ this.payloads.delete(record.key);
794
+ if (this.accepted.delete(record.key)) cursorChanged = true;
795
+ }
796
+ }
797
+ if (cursorChanged) await this.persistCursor(root);
798
+ if (retained > this.options.maxRetainedBytes) throw journalFailure("io", "runtime journal retention cap contains unacknowledged records");
799
+ }
800
+ async loadCursor(root) {
801
+ try {
802
+ const path = join(root, JOURNAL_FILES.runtimeCursor);
803
+ const info = await lstat(path);
804
+ if (!info.isFile() || info.isSymbolicLink() || info.size > JOURNAL_LIMITS.cursorBytes) throw new Error("unsafe cursor");
805
+ const parsed = JSON.parse(await readFile(path, "utf8"));
806
+ if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.acceptedItemKeys) || parsed.acceptedItemKeys.some((key) => typeof key !== "string" || key.length === 0 || key.length > 256)) throw new Error("invalid cursor");
807
+ for (const key of parsed.acceptedItemKeys) this.accepted.add(key);
808
+ } catch (error) {
809
+ if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "ENOENT") return;
810
+ throw journalFailure("corrupt", "runtime journal cursor is corrupt", error);
811
+ }
812
+ }
813
+ async persistCursor(root) {
814
+ const value = {
815
+ schemaVersion: 1,
816
+ acceptedItemKeys: [...this.accepted].sort()
817
+ };
818
+ if (Buffer.byteLength(JSON.stringify(value)) > JOURNAL_LIMITS.cursorBytes) throw journalFailure("io", "runtime journal cursor exceeds its persistence bound");
819
+ await atomicWriteJson(root, JOURNAL_FILES.runtimeCursor, value);
820
+ }
821
+ };
822
+ async function recoverRuntimeJournal(root) {
823
+ const names = (await readdir(root)).filter((name) => name.endsWith(".jsonl")).sort();
824
+ const records = [];
825
+ const payloads = /* @__PURE__ */ new Map();
826
+ const truncatedSegments = [];
827
+ const quarantinedSegments = [];
828
+ for (const name of names) {
829
+ const path = join(root, name);
830
+ const info = await lstat(path);
831
+ if (!info.isFile() || info.isSymbolicLink()) throw journalFailure("io", "runtime journal segment is not a regular file");
832
+ if (info.size > JOURNAL_LIMITS.recoverySegmentBytes) throw journalFailure("corrupt", "runtime journal segment exceeds recovery bound");
833
+ await chmod(path, 384);
834
+ let text = await readFile(path, "utf8");
835
+ if (text.length > 0 && !text.endsWith("\n")) {
836
+ const boundary = text.lastIndexOf("\n") + 1;
837
+ await truncate(path, Buffer.byteLength(text.slice(0, boundary)));
838
+ text = text.slice(0, boundary);
839
+ truncatedSegments.push(name);
840
+ }
841
+ const lines = text.length === 0 ? [] : text.slice(0, -1).split("\n");
842
+ const segmentRecords = [];
843
+ for (let index = 0; index < lines.length; index++) try {
844
+ const record = parseRuntimeJournalLine(lines[index] ?? "", name, index + 1);
845
+ const previous = payloads.get(record.key);
846
+ if (previous !== void 0 && previous !== record.payloadJson) throw new Error("conflicting duplicate item");
847
+ segmentRecords.push(record);
848
+ } catch (error) {
849
+ if (index !== lines.length - 1) throw journalFailure("corrupt", `runtime journal segment ${name} has mid-file corruption`, error);
850
+ const quarantine = `${name}.corrupt-${Date.now()}`;
851
+ await rename(path, join(root, quarantine));
852
+ quarantinedSegments.push(quarantine);
853
+ segmentRecords.length = 0;
854
+ break;
855
+ }
856
+ for (const record of segmentRecords) {
857
+ payloads.set(record.key, record.payloadJson);
858
+ records.push(record);
859
+ }
860
+ }
861
+ return deepFreeze({
862
+ records,
863
+ truncatedSegments,
864
+ quarantinedSegments
865
+ });
866
+ }
867
+ function deliveryAck(batch) {
868
+ return deepFreeze({
869
+ batchId: batch.id,
870
+ acceptedEventIds: batch.events.map((event) => event.eventId),
871
+ acceptedRunIds: batch.runRecords.map((record) => record.runId)
872
+ });
873
+ }
874
+ function abortIfRequested(signal) {
875
+ if (signal.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("runtime observation journal aborted");
876
+ }
877
+ function sameList(left, right) {
878
+ return left.length === right.length && left.every((value, index) => value === right[index]);
879
+ }
880
+ function validNow(value) {
881
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new TypeError("journal now must return a valid Date");
882
+ return value;
883
+ }
884
+ async function retainedBytes(root) {
885
+ const names = (await readdir(root)).filter((name) => name.endsWith(".jsonl"));
886
+ let total = 0;
887
+ for (const name of names) total += await stat(join(root, name)).then((value) => value.size, () => 0);
888
+ return total;
889
+ }
890
+
891
+ //#endregion
892
+ //#region src/journal/runtime-exporter.ts
893
+ /** Recommended runtime adapter. The advanced marker-free journal remains independent. */
894
+ function jsonlObservationExporter(options) {
895
+ const captured = captureRuntimeJournalOptions(options);
896
+ let journal;
897
+ let readiness;
898
+ const ready = (signal) => {
899
+ if (readiness !== void 0) return readiness;
900
+ const created = new RuntimeJsonlJournal(captured);
901
+ journal = created;
902
+ readiness = created.ready(signal);
903
+ readiness.catch(() => void 0);
904
+ return readiness;
905
+ };
906
+ const requiredJournal = () => {
907
+ if (journal === void 0) throw journalFailure("io", "runtime observation exporter is not ready");
908
+ return journal;
909
+ };
910
+ return defineObservationExporter({
911
+ id: captured.id,
912
+ supportedBoundaries: captured.supportedBoundaries,
913
+ ready,
914
+ stage(item) {
915
+ return requiredJournal().stage(item);
916
+ },
917
+ export(batch, signal) {
918
+ return requiredJournal().export(batch, signal);
919
+ },
920
+ async shutdown(signal) {
921
+ if (journal === void 0) return;
922
+ await readiness?.catch(() => void 0);
923
+ await journal.shutdown(signal);
924
+ }
925
+ });
926
+ }
927
+ /** Verify and recover records written by the recommended runtime exporter. */
928
+ async function recoverRuntimeObservationJournal(rootDir) {
929
+ const parent = await ensureSafeRoot(rootDir);
930
+ const root = await ensureSafeRoot(join(parent, JOURNAL_FILES.runtimeDirectory));
931
+ return recoverRuntimeJournal(root);
932
+ }
933
+
934
+ //#endregion
935
+ //#region src/lifecycle.ts
936
+ /** Install opt-in Node shutdown triggers and return an idempotent disposer. */
937
+ function installNodeObservabilityLifecycle(observation, options = {}) {
938
+ if (typeof observation?.shutdown !== "function") throw new TypeError("Node lifecycle requires observability.shutdown");
939
+ const target = options.target ?? process;
940
+ const events = Object.freeze(["beforeExit", ...options.signals ?? []]);
941
+ let disposed = false;
942
+ let pending;
943
+ const shutdown = () => {
944
+ if (disposed || pending !== void 0) return;
945
+ try {
946
+ pending = observation.shutdown();
947
+ pending.catch((error) => {
948
+ try {
949
+ options.onFailure?.(error);
950
+ } catch {}
951
+ });
952
+ } catch (error) {
953
+ try {
954
+ options.onFailure?.(error);
955
+ } catch {}
956
+ }
957
+ };
958
+ for (const event of events) target.on(event, shutdown);
959
+ return () => {
960
+ if (disposed) return;
961
+ disposed = true;
962
+ for (const event of events) target.off(event, shutdown);
963
+ };
964
+ }
965
+
966
+ //#endregion
967
+ export { recoverJournal as a, JsonlObservationJournalExporter as i, jsonlObservationExporter as n, recoverRuntimeObservationJournal as r, installNodeObservabilityLifecycle as t };
968
+ //# sourceMappingURL=journal-export-HAdAQxLv.mjs.map