@opengeni/contracts 0.10.0 → 0.15.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,911 @@
1
+ /**
2
+ * Canonical bounded representation for `session_events.payload`.
3
+ *
4
+ * Session events are the lossy human/audit projection, not model memory and not
5
+ * an evidence blob store. This helper keeps that projection useful while making
6
+ * the loss explicit. It deliberately has no server-only dependency so the DB,
7
+ * realtime transports, SDK/React, and tests can share one wire contract.
8
+ */
9
+
10
+ export const SESSION_EVENT_PAYLOAD_MAX_BYTES = 64 * 1024;
11
+
12
+ const TARGET_PAYLOAD_BYTES = 60 * 1024;
13
+ const DEFAULT_STRING_BYTES = 8 * 1024;
14
+ const RETRY_STRING_BYTES = 1024;
15
+ const DEFAULT_ARRAY_ENTRIES = 40;
16
+ const RETRY_ARRAY_ENTRIES = 12;
17
+ const DEFAULT_OBJECT_FIELDS = 80;
18
+ const RETRY_OBJECT_FIELDS = 32;
19
+ const MAX_PREVIEW_DEPTH = 12;
20
+ const MAX_PREVIEW_NODES = 512;
21
+ const MAX_MEASUREMENT_NODES = 2_048;
22
+ const MAX_MEASUREMENT_DEPTH = 64;
23
+ const MAX_DETAIL_RECORDS = 24;
24
+ const APPROX_BYTES_PER_TOKEN = 4;
25
+ const encoder = new TextEncoder();
26
+
27
+ const IDENTITY_FIELDS = new Set([
28
+ "id",
29
+ "callId",
30
+ "call_id",
31
+ "name",
32
+ "toolName",
33
+ "type",
34
+ "status",
35
+ "code",
36
+ "isError",
37
+ "stream",
38
+ "commandId",
39
+ "sequence",
40
+ ]);
41
+
42
+ export type SessionEventBoundarySurface =
43
+ | "durable_audit"
44
+ | "database_guard"
45
+ | "database_read_projection"
46
+ | "http_projection"
47
+ | "nats_legacy_guard"
48
+ | "sse_legacy_guard"
49
+ | "browser_legacy_guard";
50
+
51
+ export type SessionEventPayloadTruncation = {
52
+ truncated: true;
53
+ surface: SessionEventBoundarySurface;
54
+ reason:
55
+ | "payload_bytes_exceeded"
56
+ | "payload_not_serializable"
57
+ | "payload_measurement_bounded"
58
+ | "inline_media_not_retained"
59
+ | "database_guard";
60
+ originalBytes: number | null;
61
+ deliveredBytes: number;
62
+ omittedBytes: number | null;
63
+ estimatedOriginalTokens: number | null;
64
+ estimatedDeliveredTokens: number;
65
+ fullEvidence: {
66
+ available: false;
67
+ reason: "not_retained";
68
+ };
69
+ details: Array<{
70
+ path: string;
71
+ kind:
72
+ | "string"
73
+ | "array"
74
+ | "object"
75
+ | "depth"
76
+ | "budget"
77
+ | "media"
78
+ | "binary"
79
+ | "unserializable";
80
+ originalBytes?: number;
81
+ deliveredBytes?: number;
82
+ omittedEntries?: number | null;
83
+ mediaType?: string;
84
+ }>;
85
+ };
86
+
87
+ export type BoundSessionEventPayloadOptions = {
88
+ surface?: SessionEventBoundarySurface;
89
+ maxBytes?: number;
90
+ };
91
+
92
+ export type SessionEventJsonMeasurement =
93
+ | { bytes: number; reason: null }
94
+ | {
95
+ bytes: null;
96
+ reason: "payload_not_serializable" | "payload_measurement_bounded";
97
+ };
98
+
99
+ export type SessionEventMediaPreview = {
100
+ type: "media_preview";
101
+ mediaType: string;
102
+ inlineBytes: number | null;
103
+ fullOutputAvailable: false;
104
+ preview: string;
105
+ };
106
+
107
+ type PreviewState = {
108
+ changed: boolean;
109
+ sawMedia: boolean;
110
+ details: SessionEventPayloadTruncation["details"];
111
+ stringBytes: number;
112
+ arrayEntries: number;
113
+ objectFields: number;
114
+ remainingNodes: number;
115
+ };
116
+
117
+ /** UTF-8 bytes used by the JSON wire/storage representation. */
118
+ export function sessionEventJsonBytes(value: unknown): number {
119
+ return encoder.encode(stringifyForBoundary(value)).byteLength;
120
+ }
121
+
122
+ /**
123
+ * Inspect a prospective event value without invoking accessors, custom
124
+ * serializers, or allocating its complete JSON representation. A null byte
125
+ * count is explicit: the traversal stopped at the global work/depth boundary
126
+ * or found serialization behavior that must first be normalized.
127
+ */
128
+ export function measureSessionEventJson(value: unknown): SessionEventJsonMeasurement {
129
+ return measureJsonBytes(value);
130
+ }
131
+
132
+ /** The same deliberately coarse bytes/4 token estimate used by Codex parity code. */
133
+ export function approximateSessionEventTokens(bytes: number): number {
134
+ return Math.ceil(Math.max(0, bytes) / APPROX_BYTES_PER_TOKEN);
135
+ }
136
+
137
+ /** Truthful audit fact for inline media whose source bytes are not durably retained. */
138
+ export function sessionEventMediaPreview(
139
+ mediaType: string,
140
+ inlineBytes: number | null,
141
+ ): SessionEventMediaPreview {
142
+ return {
143
+ type: "media_preview",
144
+ mediaType: mediaType || "application/octet-stream",
145
+ inlineBytes:
146
+ inlineBytes === null
147
+ ? null
148
+ : Math.max(0, Math.floor(Number.isFinite(inlineBytes) ? inlineBytes : 0)),
149
+ fullOutputAvailable: false,
150
+ preview: "Inline media omitted from the audit timeline; source bytes were not retained.",
151
+ };
152
+ }
153
+
154
+ /** Parse a base64 data URL into a compact audit fact without decoding/copying its bytes. */
155
+ export function sessionEventMediaPreviewFromDataUrl(
156
+ value: string,
157
+ ): SessionEventMediaPreview | null {
158
+ const match = /^data:([^;,]+)(?:;[^,]*)?;base64,/i.exec(value);
159
+ if (!match) return null;
160
+ const encodedLength = Math.max(0, value.length - match[0].length);
161
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
162
+ return sessionEventMediaPreview(
163
+ match[1] ?? "application/octet-stream",
164
+ Math.max(0, Math.floor((encodedLength * 3) / 4) - padding),
165
+ );
166
+ }
167
+
168
+ /** Read explicit truncation metadata from a bounded object payload. */
169
+ export function sessionEventPayloadTruncation(
170
+ payload: unknown,
171
+ ): SessionEventPayloadTruncation | null {
172
+ if (!isPlainRecord(payload)) return null;
173
+ const value = payload.truncation;
174
+ if (!isPlainRecord(value) || value.truncated !== true) return null;
175
+ return value as SessionEventPayloadTruncation;
176
+ }
177
+
178
+ /**
179
+ * Return a byte-bounded audit payload. Unchanged ordinary payloads retain their
180
+ * reference. Oversized strings/containers get deterministic head+tail previews;
181
+ * inline images/binary values become metadata because `session_events` does not
182
+ * durably retain their source bytes as independently retrievable evidence.
183
+ */
184
+ export function boundSessionEventPayload<T>(
185
+ payload: T,
186
+ options: BoundSessionEventPayloadOptions = {},
187
+ ): T {
188
+ const maxBytes = Math.max(1024, Math.floor(options.maxBytes ?? SESSION_EVENT_PAYLOAD_MAX_BYTES));
189
+ const surface = options.surface ?? "durable_audit";
190
+ const originalMeasurement = measureJsonBytes(payload);
191
+ const originalBytes = originalMeasurement.bytes;
192
+
193
+ let state = previewState(DEFAULT_STRING_BYTES, DEFAULT_ARRAY_ENTRIES, DEFAULT_OBJECT_FIELDS);
194
+ let preview = previewValue(payload, state, "$", 0);
195
+ if (!state.changed && originalBytes !== null && originalBytes <= maxBytes) {
196
+ return payload;
197
+ }
198
+
199
+ let reason = payloadTruncationReason(originalMeasurement, state);
200
+ let bounded = attachTruncation(preview, boundaryMetadata(surface, reason, originalBytes, state));
201
+
202
+ if (sessionEventJsonBytes(bounded) > Math.min(maxBytes, TARGET_PAYLOAD_BYTES)) {
203
+ state = previewState(RETRY_STRING_BYTES, RETRY_ARRAY_ENTRIES, RETRY_OBJECT_FIELDS);
204
+ preview = previewValue(payload, state, "$", 0);
205
+ reason = payloadTruncationReason(originalMeasurement, state);
206
+ bounded = attachTruncation(preview, boundaryMetadata(surface, reason, originalBytes, state));
207
+ }
208
+
209
+ if (sessionEventJsonBytes(bounded) > maxBytes) {
210
+ const identity = identityPreview(payload);
211
+ state.changed = true;
212
+ recordDetail(state, {
213
+ path: "$",
214
+ kind: "object",
215
+ ...(originalBytes === null ? {} : { originalBytes }),
216
+ });
217
+ bounded = attachTruncation(
218
+ {
219
+ ...identity,
220
+ preview: "[event payload omitted: bounded audit preview exceeded the storage envelope]",
221
+ },
222
+ boundaryMetadata(surface, reason, originalBytes, state),
223
+ );
224
+ }
225
+
226
+ settleDeliveredSizes(bounded, maxBytes);
227
+ return bounded as T;
228
+ }
229
+
230
+ function previewState(
231
+ stringBytes: number,
232
+ arrayEntries: number,
233
+ objectFields: number,
234
+ ): PreviewState {
235
+ return {
236
+ changed: false,
237
+ sawMedia: false,
238
+ details: [],
239
+ stringBytes,
240
+ arrayEntries,
241
+ objectFields,
242
+ remainingNodes: MAX_PREVIEW_NODES,
243
+ };
244
+ }
245
+
246
+ function payloadTruncationReason(
247
+ measurement: JsonMeasurement,
248
+ state: PreviewState,
249
+ ): SessionEventPayloadTruncation["reason"] {
250
+ if (state.sawMedia) return "inline_media_not_retained";
251
+ return measurement.bytes === null ? measurement.reason : "payload_bytes_exceeded";
252
+ }
253
+
254
+ function previewValue(value: unknown, state: PreviewState, path: string, depth: number): unknown {
255
+ if (state.remainingNodes <= 0) {
256
+ state.changed = true;
257
+ recordDetail(state, { path, kind: "budget" });
258
+ return "[nested value omitted at audit preview traversal boundary]";
259
+ }
260
+ state.remainingNodes -= 1;
261
+ if (typeof value === "string") {
262
+ const media = inlineMediaFact(value);
263
+ if (media) {
264
+ state.changed = true;
265
+ state.sawMedia = true;
266
+ recordDetail(state, {
267
+ path,
268
+ kind: "media",
269
+ originalBytes: utf8Bytes(value),
270
+ deliveredBytes: sessionEventJsonBytes(media),
271
+ mediaType: media.mediaType,
272
+ });
273
+ return media;
274
+ }
275
+ const originalBytes = utf8Bytes(value);
276
+ if (originalBytes <= state.stringBytes) return value;
277
+ state.changed = true;
278
+ const delivered = truncateUtf8Middle(value, state.stringBytes, originalBytes);
279
+ recordDetail(state, {
280
+ path,
281
+ kind: "string",
282
+ originalBytes,
283
+ deliveredBytes: utf8Bytes(delivered),
284
+ });
285
+ return delivered;
286
+ }
287
+ if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
288
+ state.changed = true;
289
+ recordDetail(state, { path, kind: "unserializable" });
290
+ return `[${typeof value} value omitted at audit serialization boundary]`;
291
+ }
292
+ if (value === null || typeof value !== "object") return value;
293
+
294
+ const binary = binaryFact(value);
295
+ if (binary) {
296
+ state.changed = true;
297
+ recordDetail(state, {
298
+ path,
299
+ kind: "binary",
300
+ originalBytes: binary.originalBytes,
301
+ deliveredBytes: sessionEventJsonBytes(binary.preview),
302
+ });
303
+ return binary.preview;
304
+ }
305
+
306
+ if (depth >= MAX_PREVIEW_DEPTH) {
307
+ state.changed = true;
308
+ recordDetail(state, { path, kind: "depth" });
309
+ return "[nested value omitted at audit preview depth boundary]";
310
+ }
311
+
312
+ if (Array.isArray(value)) {
313
+ const keep = Math.max(2, state.arrayEntries);
314
+ if (value.length <= keep) {
315
+ return Array.from({ length: value.length }, (_, index) =>
316
+ previewArrayEntry(value, index, state, path, depth),
317
+ );
318
+ }
319
+ state.changed = true;
320
+ const head = Math.ceil(keep / 2);
321
+ const tail = Math.floor(keep / 2);
322
+ const omitted = value.length - head - tail;
323
+ recordDetail(state, { path, kind: "array", omittedEntries: omitted });
324
+ return [
325
+ ...Array.from({ length: head }, (_, index) =>
326
+ previewArrayEntry(value, index, state, path, depth),
327
+ ),
328
+ { omittedEntries: omitted, preview: "[middle array entries omitted]" },
329
+ ...Array.from({ length: tail }, (_, index) =>
330
+ previewArrayEntry(value, value.length - tail + index, state, path, depth),
331
+ ),
332
+ ];
333
+ }
334
+
335
+ if (value instanceof Date) {
336
+ if (!hasCanonicalDateSerialization(value)) {
337
+ state.changed = true;
338
+ recordDetail(state, { path, kind: "unserializable" });
339
+ return "[Date value with custom serialization omitted at audit boundary]";
340
+ }
341
+ try {
342
+ const epoch = Date.prototype.getTime.call(value);
343
+ return Number.isFinite(epoch) ? Date.prototype.toISOString.call(value) : null;
344
+ } catch {
345
+ state.changed = true;
346
+ recordDetail(state, { path, kind: "unserializable" });
347
+ return "[Date value omitted at audit serialization boundary]";
348
+ }
349
+ }
350
+
351
+ const record = value as Record<string, unknown>;
352
+ const { entries, omitted, accessorKeys } = selectObjectEntries(record, state.objectFields);
353
+ for (const key of accessorKeys) {
354
+ state.changed = true;
355
+ recordDetail(state, {
356
+ path: `${path}.${key}`,
357
+ kind: "unserializable",
358
+ });
359
+ }
360
+ if (omitted) {
361
+ state.changed = true;
362
+ recordDetail(state, {
363
+ path,
364
+ kind: "object",
365
+ // Counting every remaining field would defeat the traversal bound. `null`
366
+ // is deliberate: at least one field was omitted, but its exact count is
367
+ // unknown because the source was not fully enumerated.
368
+ omittedEntries: null,
369
+ });
370
+ }
371
+ const out: Record<string, unknown> = {};
372
+ for (const [key, entry] of entries) {
373
+ out[key] = previewValue(entry, state, `${path}.${key}`, depth + 1);
374
+ }
375
+ if (omitted) {
376
+ out.omittedFields = "additional fields omitted; exact count not measured";
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function previewArrayEntry(
382
+ value: unknown[],
383
+ index: number,
384
+ state: PreviewState,
385
+ path: string,
386
+ depth: number,
387
+ ): unknown {
388
+ let descriptor: PropertyDescriptor | undefined;
389
+ try {
390
+ descriptor = Object.getOwnPropertyDescriptor(value, String(index));
391
+ } catch {
392
+ state.changed = true;
393
+ recordDetail(state, { path: `${path}[${index}]`, kind: "unserializable" });
394
+ return "[array element omitted at audit serialization boundary]";
395
+ }
396
+ if (descriptor && !("value" in descriptor)) {
397
+ state.changed = true;
398
+ recordDetail(state, { path: `${path}[${index}]`, kind: "unserializable" });
399
+ return "[array accessor value omitted at audit serialization boundary]";
400
+ }
401
+ return previewValue(
402
+ descriptor && "value" in descriptor ? descriptor.value : undefined,
403
+ state,
404
+ `${path}[${index}]`,
405
+ depth + 1,
406
+ );
407
+ }
408
+
409
+ function selectObjectEntries(
410
+ record: Record<string, unknown>,
411
+ maxFields: number,
412
+ ): {
413
+ entries: Array<[string, unknown]>;
414
+ omitted: boolean;
415
+ accessorKeys: string[];
416
+ } {
417
+ const entries: Array<[string, unknown]> = [];
418
+ const selected = new Set<string>();
419
+ const accessorKeys: string[] = [];
420
+ const select = (key: string): boolean => {
421
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
422
+ if (!descriptor?.enumerable) return false;
423
+ selected.add(key);
424
+ if ("value" in descriptor) {
425
+ entries.push([key, descriptor.value]);
426
+ } else {
427
+ entries.push([key, "[accessor value omitted at audit serialization boundary]"]);
428
+ accessorKeys.push(key);
429
+ }
430
+ return true;
431
+ };
432
+ try {
433
+ for (const key of IDENTITY_FIELDS) {
434
+ if (entries.length >= maxFields) break;
435
+ select(key);
436
+ }
437
+ for (const key in record) {
438
+ if (!Object.prototype.hasOwnProperty.call(record, key) || selected.has(key)) continue;
439
+ if (entries.length >= maxFields) {
440
+ return { entries, omitted: true, accessorKeys };
441
+ }
442
+ select(key);
443
+ }
444
+ } catch {
445
+ return { entries, omitted: true, accessorKeys };
446
+ }
447
+ return { entries, omitted: false, accessorKeys };
448
+ }
449
+
450
+ function attachTruncation(
451
+ preview: unknown,
452
+ truncation: SessionEventPayloadTruncation,
453
+ ): Record<string, unknown> {
454
+ if (isPlainRecord(preview)) {
455
+ // `truncation` is reserved boundary metadata. A producer-supplied value is
456
+ // intentionally replaced when the boundary actually omits content.
457
+ return { ...preview, truncation };
458
+ }
459
+ return { value: preview, truncation };
460
+ }
461
+
462
+ function boundaryMetadata(
463
+ surface: SessionEventBoundarySurface,
464
+ reason: SessionEventPayloadTruncation["reason"],
465
+ originalBytes: number | null,
466
+ state: PreviewState,
467
+ ): SessionEventPayloadTruncation {
468
+ return {
469
+ truncated: true,
470
+ surface,
471
+ reason,
472
+ originalBytes,
473
+ deliveredBytes: 0,
474
+ omittedBytes: originalBytes,
475
+ estimatedOriginalTokens:
476
+ originalBytes === null ? null : approximateSessionEventTokens(originalBytes),
477
+ estimatedDeliveredTokens: 0,
478
+ fullEvidence: { available: false, reason: "not_retained" },
479
+ details: state.details,
480
+ };
481
+ }
482
+
483
+ function settleDeliveredSizes(payload: Record<string, unknown>, maxBytes: number): void {
484
+ const truncation = payload.truncation as SessionEventPayloadTruncation;
485
+ convergeDeliveredSizes(payload, truncation);
486
+ // This should only be reachable when a caller supplies an unusually tiny
487
+ // custom maxBytes. Preserve explicit metadata over an accidental overshoot.
488
+ if (sessionEventJsonBytes(payload) > maxBytes) {
489
+ payload.preview = "[event payload omitted at audit byte boundary]";
490
+ for (const key of Object.keys(payload)) {
491
+ if (key !== "preview" && key !== "truncation" && IDENTITY_FIELDS.has(key) === false) {
492
+ delete payload[key];
493
+ }
494
+ }
495
+ truncation.details = truncation.details.slice(0, 4);
496
+ convergeDeliveredSizes(payload, truncation);
497
+ }
498
+ }
499
+
500
+ function convergeDeliveredSizes(
501
+ payload: Record<string, unknown>,
502
+ truncation: SessionEventPayloadTruncation,
503
+ ): void {
504
+ for (let attempt = 0; attempt < 16; attempt += 1) {
505
+ const deliveredBytes = sessionEventJsonBytes(payload);
506
+ const omittedBytes =
507
+ truncation.originalBytes === null
508
+ ? null
509
+ : Math.max(0, truncation.originalBytes - deliveredBytes);
510
+ const estimatedDeliveredTokens = approximateSessionEventTokens(deliveredBytes);
511
+ if (
512
+ truncation.deliveredBytes === deliveredBytes &&
513
+ truncation.omittedBytes === omittedBytes &&
514
+ truncation.estimatedDeliveredTokens === estimatedDeliveredTokens
515
+ ) {
516
+ return;
517
+ }
518
+ truncation.deliveredBytes = deliveredBytes;
519
+ truncation.omittedBytes = omittedBytes;
520
+ truncation.estimatedDeliveredTokens = estimatedDeliveredTokens;
521
+ }
522
+ throw new RangeError("Session event byte accounting did not converge");
523
+ }
524
+
525
+ function recordDetail(
526
+ state: PreviewState,
527
+ detail: SessionEventPayloadTruncation["details"][number],
528
+ ): void {
529
+ if (state.details.length < MAX_DETAIL_RECORDS) state.details.push(detail);
530
+ }
531
+
532
+ function identityPreview(value: unknown): Record<string, unknown> {
533
+ if (!isPlainRecord(value)) return {};
534
+ const out: Record<string, unknown> = {};
535
+ for (const key of IDENTITY_FIELDS) {
536
+ let field: unknown;
537
+ try {
538
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
539
+ if (!descriptor?.enumerable || !("value" in descriptor)) continue;
540
+ field = descriptor.value;
541
+ } catch {
542
+ continue;
543
+ }
544
+ if (typeof field === "string") {
545
+ out[key] = truncateUtf8Middle(field, 256, utf8Bytes(field));
546
+ } else if (typeof field === "number" || typeof field === "boolean" || field === null) {
547
+ out[key] = field;
548
+ }
549
+ }
550
+ return out;
551
+ }
552
+
553
+ function inlineMediaFact(value: string): SessionEventMediaPreview | null {
554
+ return sessionEventMediaPreviewFromDataUrl(value);
555
+ }
556
+
557
+ function binaryFact(value: object): {
558
+ originalBytes: number;
559
+ preview: {
560
+ type: "binary_preview";
561
+ byteLength: number;
562
+ fullOutputAvailable: false;
563
+ preview: string;
564
+ };
565
+ } | null {
566
+ if (ArrayBuffer.isView(value)) {
567
+ return {
568
+ originalBytes: value.byteLength,
569
+ preview: {
570
+ type: "binary_preview",
571
+ byteLength: value.byteLength,
572
+ fullOutputAvailable: false,
573
+ preview: "Inline binary value omitted from the audit timeline; bytes were not retained.",
574
+ },
575
+ };
576
+ }
577
+ if (value instanceof ArrayBuffer) {
578
+ return {
579
+ originalBytes: value.byteLength,
580
+ preview: {
581
+ type: "binary_preview",
582
+ byteLength: value.byteLength,
583
+ fullOutputAvailable: false,
584
+ preview: "Inline binary value omitted from the audit timeline; bytes were not retained.",
585
+ },
586
+ };
587
+ }
588
+ return null;
589
+ }
590
+
591
+ function truncateUtf8Middle(
592
+ value: string,
593
+ maxBytes: number,
594
+ knownBytes = utf8Bytes(value),
595
+ ): string {
596
+ if (knownBytes <= maxBytes) return value;
597
+ let omittedBytes = Math.max(0, knownBytes - maxBytes);
598
+ for (let attempt = 0; attempt < 4; attempt += 1) {
599
+ const marker = `…[${omittedBytes} bytes omitted]…`;
600
+ const contentBudget = Math.max(0, maxBytes - utf8Bytes(marker));
601
+ if (contentBudget === 0) return marker;
602
+ const left = utf8Prefix(value, Math.floor(contentBudget / 2));
603
+ const right = utf8Suffix(value, contentBudget - left.bytes);
604
+ const exactOmittedBytes = Math.max(0, knownBytes - left.bytes - right.bytes);
605
+ if (exactOmittedBytes === omittedBytes) {
606
+ return `${value.slice(0, left.end)}${marker}${value.slice(right.start)}`;
607
+ }
608
+ omittedBytes = exactOmittedBytes;
609
+ }
610
+
611
+ const marker = `…[${omittedBytes} bytes omitted]…`;
612
+ const contentBudget = Math.max(0, maxBytes - utf8Bytes(marker));
613
+ const left = utf8Prefix(value, Math.floor(contentBudget / 2));
614
+ const right = utf8Suffix(value, contentBudget - left.bytes);
615
+ return `${value.slice(0, left.end)}${marker}${value.slice(right.start)}`;
616
+ }
617
+
618
+ function utf8Prefix(value: string, maxBytes: number): { end: number; bytes: number } {
619
+ let end = 0;
620
+ let bytes = 0;
621
+ while (end < value.length) {
622
+ const unit = utf8UnitAt(value, end);
623
+ if (bytes + unit.bytes > maxBytes) break;
624
+ bytes += unit.bytes;
625
+ end += unit.codeUnits;
626
+ }
627
+ return { end, bytes };
628
+ }
629
+
630
+ function utf8Suffix(value: string, maxBytes: number): { start: number; bytes: number } {
631
+ let start = value.length;
632
+ let bytes = 0;
633
+ while (start > 0) {
634
+ const unit = utf8UnitBefore(value, start);
635
+ if (bytes + unit.bytes > maxBytes) break;
636
+ bytes += unit.bytes;
637
+ start -= unit.codeUnits;
638
+ }
639
+ return { start, bytes };
640
+ }
641
+
642
+ function utf8UnitAt(value: string, index: number): { codeUnits: number; bytes: number } {
643
+ const code = value.charCodeAt(index);
644
+ if (code <= 0x7f) return { codeUnits: 1, bytes: 1 };
645
+ if (code <= 0x7ff) return { codeUnits: 1, bytes: 2 };
646
+ if (code >= 0xd800 && code <= 0xdbff) {
647
+ const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
648
+ if (next >= 0xdc00 && next <= 0xdfff) {
649
+ return { codeUnits: 2, bytes: 4 };
650
+ }
651
+ }
652
+ return { codeUnits: 1, bytes: 3 };
653
+ }
654
+
655
+ function utf8UnitBefore(value: string, end: number): { codeUnits: number; bytes: number } {
656
+ const code = value.charCodeAt(end - 1);
657
+ if (code >= 0xdc00 && code <= 0xdfff && end >= 2) {
658
+ const previous = value.charCodeAt(end - 2);
659
+ if (previous >= 0xd800 && previous <= 0xdbff) {
660
+ return { codeUnits: 2, bytes: 4 };
661
+ }
662
+ }
663
+ if (code <= 0x7f) return { codeUnits: 1, bytes: 1 };
664
+ if (code <= 0x7ff) return { codeUnits: 1, bytes: 2 };
665
+ return { codeUnits: 1, bytes: 3 };
666
+ }
667
+
668
+ function utf8Bytes(value: string): number {
669
+ let bytes = 0;
670
+ for (let index = 0; index < value.length;) {
671
+ const unit = utf8UnitAt(value, index);
672
+ bytes += unit.bytes;
673
+ index += unit.codeUnits;
674
+ }
675
+ return bytes;
676
+ }
677
+
678
+ /**
679
+ * Count the JSON wire bytes without first allocating the complete JSON string.
680
+ * A global node budget prevents an adversarial broad graph from moving the
681
+ * durable 64 KiB bound into an unbounded measurement pass. `null` means exact
682
+ * source size is unknown; callers expose that truth instead of measuring a
683
+ * placeholder or claiming a lower bound is exact.
684
+ */
685
+ type JsonMeasurement = SessionEventJsonMeasurement;
686
+
687
+ function measureJsonBytes(value: unknown): JsonMeasurement {
688
+ const state: JsonMeasurementState = {
689
+ remainingNodes: MAX_MEASUREMENT_NODES,
690
+ seen: new WeakSet<object>(),
691
+ failureReason: null,
692
+ };
693
+ try {
694
+ const bytes = measureJsonValue(value, state, "top", 0);
695
+ return bytes === null
696
+ ? {
697
+ bytes: null,
698
+ reason: state.failureReason ?? "payload_measurement_bounded",
699
+ }
700
+ : { bytes, reason: null };
701
+ } catch {
702
+ return { bytes: null, reason: "payload_measurement_bounded" };
703
+ }
704
+ }
705
+
706
+ type JsonMeasurementState = {
707
+ remainingNodes: number;
708
+ seen: WeakSet<object>;
709
+ failureReason: "payload_not_serializable" | "payload_measurement_bounded" | null;
710
+ };
711
+
712
+ function measureJsonValue(
713
+ value: unknown,
714
+ state: JsonMeasurementState,
715
+ position: "top" | "array" | "object",
716
+ depth: number,
717
+ ): number | null {
718
+ if (state.remainingNodes <= 0 || depth > MAX_MEASUREMENT_DEPTH) {
719
+ return failJsonMeasurement(state, "payload_measurement_bounded");
720
+ }
721
+ state.remainingNodes -= 1;
722
+ if (value === null) return 4;
723
+ if (typeof value === "string") return jsonStringBytes(value);
724
+ if (typeof value === "boolean") return value ? 4 : 5;
725
+ if (typeof value === "number") {
726
+ const serialized = JSON.stringify(value);
727
+ return serialized === undefined ? null : serialized.length;
728
+ }
729
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
730
+ return position === "array" ? 4 : failJsonMeasurement(state, "payload_not_serializable");
731
+ }
732
+ if (typeof value === "bigint") {
733
+ return failJsonMeasurement(state, "payload_not_serializable");
734
+ }
735
+ if (typeof value !== "object") {
736
+ return failJsonMeasurement(state, "payload_not_serializable");
737
+ }
738
+ if (value instanceof Date) {
739
+ if (!hasCanonicalDateSerialization(value)) {
740
+ return failJsonMeasurement(state, "payload_measurement_bounded");
741
+ }
742
+ try {
743
+ const epoch = Date.prototype.getTime.call(value);
744
+ return Number.isFinite(epoch) ? jsonStringBytes(Date.prototype.toISOString.call(value)) : 4;
745
+ } catch {
746
+ return failJsonMeasurement(state, "payload_not_serializable");
747
+ }
748
+ }
749
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
750
+ return failJsonMeasurement(state, "payload_measurement_bounded");
751
+ }
752
+ if (state.seen.has(value)) {
753
+ return failJsonMeasurement(state, "payload_not_serializable");
754
+ }
755
+ state.seen.add(value);
756
+ try {
757
+ if (Array.isArray(value)) {
758
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
759
+ return failJsonMeasurement(state, "payload_measurement_bounded");
760
+ }
761
+ const toJson = Object.getOwnPropertyDescriptor(value, "toJSON");
762
+ if (toJson && ("get" in toJson || typeof toJson.value === "function")) {
763
+ return failJsonMeasurement(state, "payload_measurement_bounded");
764
+ }
765
+ if (value.length > state.remainingNodes) {
766
+ return failJsonMeasurement(state, "payload_measurement_bounded");
767
+ }
768
+ let bytes = 2;
769
+ for (let index = 0; index < value.length; index += 1) {
770
+ if (index > 0) bytes += 1;
771
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
772
+ if (descriptor && !("value" in descriptor)) {
773
+ return failJsonMeasurement(state, "payload_measurement_bounded");
774
+ }
775
+ const entry = descriptor && "value" in descriptor ? descriptor.value : undefined;
776
+ const measured = measureJsonValue(entry, state, "array", depth + 1);
777
+ if (measured === null) return null;
778
+ bytes += measured;
779
+ }
780
+ return bytes;
781
+ }
782
+
783
+ const prototype = Object.getPrototypeOf(value);
784
+ if (prototype !== Object.prototype && prototype !== null) {
785
+ return failJsonMeasurement(state, "payload_measurement_bounded");
786
+ }
787
+ const toJson = Object.getOwnPropertyDescriptor(value, "toJSON");
788
+ if (toJson && ("get" in toJson || typeof toJson.value === "function")) {
789
+ return failJsonMeasurement(state, "payload_measurement_bounded");
790
+ }
791
+ let bytes = 2;
792
+ let fields = 0;
793
+ try {
794
+ for (const key in value as Record<string, unknown>) {
795
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
796
+ if (state.remainingNodes <= 0) {
797
+ return failJsonMeasurement(state, "payload_measurement_bounded");
798
+ }
799
+ // A property consumes traversal work even when JSON.stringify would
800
+ // omit its value. Without this decrement a broad object containing
801
+ // only undefined/functions/symbols bypasses the global node budget.
802
+ state.remainingNodes -= 1;
803
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
804
+ if (!descriptor || !("value" in descriptor)) {
805
+ return failJsonMeasurement(state, "payload_measurement_bounded");
806
+ }
807
+ const entry = descriptor.value;
808
+ if (
809
+ typeof entry === "undefined" ||
810
+ typeof entry === "function" ||
811
+ typeof entry === "symbol"
812
+ ) {
813
+ continue;
814
+ }
815
+ const measured = measureJsonValue(entry, state, "object", depth + 1);
816
+ if (measured === null) return null;
817
+ if (fields > 0) bytes += 1;
818
+ bytes += jsonStringBytes(key) + 1 + measured;
819
+ fields += 1;
820
+ }
821
+ } catch {
822
+ return null;
823
+ }
824
+ return bytes;
825
+ } finally {
826
+ state.seen.delete(value);
827
+ }
828
+ }
829
+
830
+ function hasCanonicalDateSerialization(value: Date): boolean {
831
+ try {
832
+ if (Object.getPrototypeOf(value) !== Date.prototype) return false;
833
+ // JSON.stringify performs an ordinary property lookup for toJSON. Any own
834
+ // shadow (data or accessor) changes that behavior and must not be invoked.
835
+ return Object.getOwnPropertyDescriptor(value, "toJSON") === undefined;
836
+ } catch {
837
+ return false;
838
+ }
839
+ }
840
+
841
+ function failJsonMeasurement(
842
+ state: JsonMeasurementState,
843
+ reason: Exclude<JsonMeasurement, { bytes: number }>["reason"],
844
+ ): null {
845
+ state.failureReason ??= reason;
846
+ return null;
847
+ }
848
+
849
+ function jsonStringBytes(value: string): number {
850
+ let bytes = 2;
851
+ for (let index = 0; index < value.length; index += 1) {
852
+ const code = value.charCodeAt(index);
853
+ if (
854
+ code === 0x22 ||
855
+ code === 0x5c ||
856
+ code === 0x08 ||
857
+ code === 0x09 ||
858
+ code === 0x0a ||
859
+ code === 0x0c ||
860
+ code === 0x0d
861
+ ) {
862
+ bytes += 2;
863
+ } else if (code <= 0x1f) {
864
+ bytes += 6;
865
+ } else if (code <= 0x7f) {
866
+ bytes += 1;
867
+ } else if (code <= 0x7ff) {
868
+ bytes += 2;
869
+ } else if (code >= 0xd800 && code <= 0xdbff) {
870
+ const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
871
+ if (next >= 0xdc00 && next <= 0xdfff) {
872
+ bytes += 4;
873
+ index += 1;
874
+ } else {
875
+ // JSON.stringify escapes lone surrogates as six ASCII bytes.
876
+ bytes += 6;
877
+ }
878
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
879
+ bytes += 6;
880
+ } else {
881
+ bytes += 3;
882
+ }
883
+ }
884
+ return bytes;
885
+ }
886
+
887
+ function serializeForBoundary(value: unknown): {
888
+ value: string;
889
+ serializable: boolean;
890
+ } {
891
+ try {
892
+ const serialized = JSON.stringify(value);
893
+ return {
894
+ value: serialized === undefined ? "null" : serialized,
895
+ serializable: serialized !== undefined,
896
+ };
897
+ } catch {
898
+ return {
899
+ value: JSON.stringify("[unserializable event payload omitted]"),
900
+ serializable: false,
901
+ };
902
+ }
903
+ }
904
+
905
+ function stringifyForBoundary(value: unknown): string {
906
+ return serializeForBoundary(value).value;
907
+ }
908
+
909
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
910
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
911
+ }