@fluxpointstudios/orynq-sdk-process-trace 0.2.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,687 @@
1
+ /**
2
+ * @fileoverview Manifest creation and verification for off-chain trace storage.
3
+ *
4
+ * Location: packages/process-trace/src/manifest.ts
5
+ *
6
+ * This module provides functionality for creating manifests and chunks from trace bundles,
7
+ * enabling efficient off-chain storage of trace data. The manifest serves as a public-safe
8
+ * entry point for trace retrieval, while chunks contain the actual span and event data.
9
+ *
10
+ * Key features:
11
+ * - Creates manifests with cryptographic commitments for integrity verification
12
+ * - Chunks trace data by size for efficient storage and retrieval
13
+ * - Provides verification functions to ensure manifest and chunk integrity
14
+ * - Supports reconstruction of trace bundles from manifests and chunks
15
+ *
16
+ * Storage Layout (for consumers):
17
+ * ```
18
+ * <storageUri>/
19
+ * manifest.json # TraceManifest (public-safe)
20
+ * chunks/
21
+ * <hash1>.json # Or .json.gz if compressed by consumer
22
+ * <hash2>.json
23
+ * ```
24
+ *
25
+ * Used by:
26
+ * - Storage adapters for persisting trace data
27
+ * - Retrieval workflows for reconstructing traces
28
+ * - Verification workflows for validating stored traces
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * // Create manifest for storage
33
+ * const { manifest, chunks } = await createManifest(bundle, { chunkSize: 500_000 });
34
+ *
35
+ * // Store chunks (consumer handles actual storage)
36
+ * for (const chunk of chunks) {
37
+ * const path = getChunkPath(chunk.info);
38
+ * await storage.write(path, chunk.content);
39
+ * }
40
+ *
41
+ * // Store manifest
42
+ * await storage.write("manifest.json", JSON.stringify(manifest));
43
+ *
44
+ * // Later: verify
45
+ * const result = await verifyManifest(manifest, loadedChunks);
46
+ * if (!result.valid) {
47
+ * console.error("Manifest verification failed:", result.errors);
48
+ * }
49
+ * ```
50
+ */
51
+
52
+ import { sha256StringHex, canonicalize } from "@fluxpointstudios/orynq-sdk-core/utils";
53
+ import type {
54
+ TraceManifest,
55
+ TraceBundle,
56
+ ChunkInfo,
57
+ Chunk,
58
+ CreateManifestOptions,
59
+ ManifestVerificationResult,
60
+ TraceSpan,
61
+ TraceEvent,
62
+ TraceRun,
63
+ } from "./types.js";
64
+ import { HASH_DOMAIN_PREFIXES } from "./types.js";
65
+
66
+ // =============================================================================
67
+ // CONSTANTS
68
+ // =============================================================================
69
+
70
+ /**
71
+ * Default chunk size in bytes (1MB of uncompressed JSON).
72
+ * This is the target size for chunking; actual chunks may be slightly larger
73
+ * to avoid splitting spans across chunks.
74
+ */
75
+ const DEFAULT_CHUNK_SIZE = 1_000_000;
76
+
77
+ // =============================================================================
78
+ // CHUNK CONTENT TYPE
79
+ // =============================================================================
80
+
81
+ /**
82
+ * Content format for a chunk.
83
+ * Contains spans and their associated events.
84
+ */
85
+ interface ChunkContent {
86
+ spans: TraceSpan[];
87
+ events: TraceEvent[];
88
+ }
89
+
90
+ // =============================================================================
91
+ // MANIFEST CREATION
92
+ // =============================================================================
93
+
94
+ /**
95
+ * Create a manifest and chunks from a trace bundle.
96
+ *
97
+ * Chunks contain private span/event data for off-chain storage. Each chunk
98
+ * includes a subset of spans and their associated events, grouped to fit
99
+ * within the target chunk size.
100
+ *
101
+ * The manifest contains:
102
+ * - Metadata from the bundle (runId, agentId, timestamps, etc.)
103
+ * - Cryptographic commitments (rootHash, merkleRoot)
104
+ * - Chunk information (index, hash, size, spanIds)
105
+ * - The complete publicView for quick access
106
+ *
107
+ * @param bundle - Finalized trace bundle to create manifest from
108
+ * @param options - Optional chunking options
109
+ * @param options.chunkSize - Target chunk size in bytes (default: 1MB)
110
+ * @param options.compression - Compression hint for consumers (default: "none")
111
+ * @returns Promise resolving to manifest and array of chunks
112
+ *
113
+ * @example
114
+ * ```typescript
115
+ * const { manifest, chunks } = await createManifest(bundle, {
116
+ * chunkSize: 500_000, // 500KB chunks
117
+ * });
118
+ *
119
+ * console.log(`Created ${chunks.length} chunks`);
120
+ * console.log(`Manifest hash: ${manifest.manifestHash}`);
121
+ * ```
122
+ */
123
+ export async function createManifest(
124
+ bundle: TraceBundle,
125
+ options?: CreateManifestOptions
126
+ ): Promise<{ manifest: TraceManifest; chunks: Chunk[] }> {
127
+ const chunkSize = options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
128
+ const compression = options?.compression ?? "none";
129
+
130
+ const run = bundle.privateRun;
131
+
132
+ // Build event lookup map for efficient access
133
+ const eventMap = new Map<string, TraceEvent>();
134
+ for (const event of run.events) {
135
+ eventMap.set(event.id, event);
136
+ }
137
+
138
+ // Group spans into chunks based on size
139
+ const chunks: Chunk[] = [];
140
+ let currentChunkSpans: TraceSpan[] = [];
141
+ let currentChunkEvents: TraceEvent[] = [];
142
+ let currentChunkSize = 0;
143
+ let chunkIndex = 0;
144
+
145
+ // Sort spans by spanSeq for deterministic ordering
146
+ const sortedSpans = [...run.spans].sort((a, b) => a.spanSeq - b.spanSeq);
147
+
148
+ for (const span of sortedSpans) {
149
+ // Get events for this span
150
+ const spanEvents = span.eventIds
151
+ .map((id) => eventMap.get(id))
152
+ .filter((e): e is TraceEvent => e !== undefined)
153
+ .sort((a, b) => a.seq - b.seq);
154
+
155
+ // Estimate size of this span and its events
156
+ const spanJson = JSON.stringify(span);
157
+ const eventsJson = spanEvents.map((e) => JSON.stringify(e)).join("");
158
+ const spanSize = spanJson.length + eventsJson.length;
159
+
160
+ // If adding this span would exceed chunk size and we have content, finalize current chunk
161
+ if (currentChunkSize + spanSize > chunkSize && currentChunkSpans.length > 0) {
162
+ const chunk = await createChunk(
163
+ chunkIndex,
164
+ currentChunkSpans,
165
+ currentChunkEvents,
166
+ compression
167
+ );
168
+ chunks.push(chunk);
169
+ chunkIndex++;
170
+
171
+ // Reset for next chunk
172
+ currentChunkSpans = [];
173
+ currentChunkEvents = [];
174
+ currentChunkSize = 0;
175
+ }
176
+
177
+ // Add span and its events to current chunk
178
+ currentChunkSpans.push(span);
179
+ currentChunkEvents.push(...spanEvents);
180
+ currentChunkSize += spanSize;
181
+ }
182
+
183
+ // Create final chunk if there's remaining content
184
+ if (currentChunkSpans.length > 0) {
185
+ const chunk = await createChunk(
186
+ chunkIndex,
187
+ currentChunkSpans,
188
+ currentChunkEvents,
189
+ compression
190
+ );
191
+ chunks.push(chunk);
192
+ }
193
+
194
+ // Build manifest without hash first
195
+ const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
196
+ formatVersion: bundle.formatVersion,
197
+ runId: run.id,
198
+ agentId: run.agentId,
199
+ rootHash: bundle.rootHash,
200
+ merkleRoot: bundle.merkleRoot,
201
+ totalEvents: run.events.length,
202
+ totalSpans: run.spans.length,
203
+ startedAt: run.startedAt,
204
+ endedAt: run.endedAt ?? run.startedAt,
205
+ durationMs: run.durationMs ?? 0,
206
+ chunks: chunks.map((c) => c.info),
207
+ publicView: bundle.publicView,
208
+ };
209
+
210
+ // Compute manifest hash
211
+ const manifestHash = await computeManifestHash(manifestWithoutHash);
212
+
213
+ // Build complete manifest
214
+ const manifest: TraceManifest = {
215
+ ...manifestWithoutHash,
216
+ manifestHash,
217
+ };
218
+
219
+ return { manifest, chunks };
220
+ }
221
+
222
+ /**
223
+ * Internal helper to create a chunk from spans and events.
224
+ *
225
+ * @param index - Chunk sequence number
226
+ * @param spans - Spans to include in this chunk
227
+ * @param events - Events to include in this chunk
228
+ * @param compression - Compression hint
229
+ * @returns Promise resolving to the complete Chunk
230
+ */
231
+ async function createChunk(
232
+ index: number,
233
+ spans: TraceSpan[],
234
+ events: TraceEvent[],
235
+ compression: "gzip" | "none"
236
+ ): Promise<Chunk> {
237
+ // Build chunk content
238
+ const content: ChunkContent = {
239
+ spans,
240
+ events,
241
+ };
242
+
243
+ // Serialize to JSON (deterministic ordering via canonicalize)
244
+ const contentJson = canonicalize(content);
245
+
246
+ // Compute hash of content BEFORE any compression
247
+ const hash = await sha256StringHex(contentJson);
248
+
249
+ // Get span IDs for this chunk
250
+ const spanIds = spans.map((s) => s.id);
251
+
252
+ // Build chunk info
253
+ const info: ChunkInfo = {
254
+ index,
255
+ hash,
256
+ size: contentJson.length,
257
+ compression,
258
+ spanIds,
259
+ };
260
+
261
+ return {
262
+ info,
263
+ content: contentJson,
264
+ };
265
+ }
266
+
267
+ // =============================================================================
268
+ // MANIFEST HASH COMPUTATION
269
+ // =============================================================================
270
+
271
+ /**
272
+ * Compute the manifest hash.
273
+ *
274
+ * The manifest hash is computed as:
275
+ * `H("poi-trace:manifest:v1|" + canonical(manifestWithoutHash))`
276
+ *
277
+ * This hash serves as a cryptographic commitment to the manifest contents,
278
+ * enabling integrity verification of stored manifests.
279
+ *
280
+ * @param manifest - Manifest object without the manifestHash field
281
+ * @returns Promise resolving to the manifest hash as a hex string
282
+ *
283
+ * @example
284
+ * ```typescript
285
+ * const manifestHash = await computeManifestHash(manifestWithoutHash);
286
+ * const completeManifest = { ...manifestWithoutHash, manifestHash };
287
+ * ```
288
+ */
289
+ export async function computeManifestHash(
290
+ manifest: Omit<TraceManifest, "manifestHash">
291
+ ): Promise<string> {
292
+ // Canonicalize the manifest for deterministic serialization
293
+ const canonical = canonicalize(manifest);
294
+
295
+ // Apply domain separation and hash
296
+ const prefixedData = HASH_DOMAIN_PREFIXES.manifest + canonical;
297
+
298
+ return sha256StringHex(prefixedData);
299
+ }
300
+
301
+ // =============================================================================
302
+ // MANIFEST VERIFICATION
303
+ // =============================================================================
304
+
305
+ /**
306
+ * Verify a manifest against its chunks.
307
+ *
308
+ * Performs comprehensive validation including:
309
+ * - Manifest hash matches recomputed hash
310
+ * - Each chunk hash matches its content
311
+ * - All chunk indices are present
312
+ * - Root hash in manifest is present
313
+ * - Merkle root in manifest is present
314
+ *
315
+ * @param manifest - The manifest to verify
316
+ * @param chunks - The chunks referenced by the manifest
317
+ * @returns Promise resolving to verification result with errors and check statuses
318
+ *
319
+ * @example
320
+ * ```typescript
321
+ * const result = await verifyManifest(manifest, chunks);
322
+ * if (!result.valid) {
323
+ * console.error("Verification failed:", result.errors);
324
+ * } else {
325
+ * console.log("Manifest and chunks are valid");
326
+ * }
327
+ * ```
328
+ */
329
+ export async function verifyManifest(
330
+ manifest: TraceManifest,
331
+ chunks: Chunk[]
332
+ ): Promise<ManifestVerificationResult> {
333
+ const errors: string[] = [];
334
+ const warnings: string[] = [];
335
+ const checks = {
336
+ manifestHashValid: false,
337
+ chunkHashesValid: false,
338
+ rootHashMatches: false,
339
+ merkleRootMatches: false,
340
+ };
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Verify Manifest Hash
344
+ // ---------------------------------------------------------------------------
345
+
346
+ try {
347
+ // Create manifest without hash for recomputation
348
+ const manifestWithoutHash: Omit<TraceManifest, "manifestHash"> = {
349
+ formatVersion: manifest.formatVersion,
350
+ runId: manifest.runId,
351
+ agentId: manifest.agentId,
352
+ rootHash: manifest.rootHash,
353
+ merkleRoot: manifest.merkleRoot,
354
+ totalEvents: manifest.totalEvents,
355
+ totalSpans: manifest.totalSpans,
356
+ startedAt: manifest.startedAt,
357
+ endedAt: manifest.endedAt,
358
+ durationMs: manifest.durationMs,
359
+ chunks: manifest.chunks,
360
+ publicView: manifest.publicView,
361
+ };
362
+
363
+ const computedHash = await computeManifestHash(manifestWithoutHash);
364
+
365
+ if (manifest.manifestHash === computedHash) {
366
+ checks.manifestHashValid = true;
367
+ } else {
368
+ errors.push(
369
+ `Manifest hash mismatch: expected ${manifest.manifestHash}, computed ${computedHash}`
370
+ );
371
+ }
372
+ } catch (error) {
373
+ errors.push(
374
+ `Failed to compute manifest hash: ${error instanceof Error ? error.message : String(error)}`
375
+ );
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Verify Chunk Hashes
380
+ // ---------------------------------------------------------------------------
381
+
382
+ // Build chunk lookup by index
383
+ const chunkByIndex = new Map<number, Chunk>();
384
+ for (const chunk of chunks) {
385
+ chunkByIndex.set(chunk.info.index, chunk);
386
+ }
387
+
388
+ let allChunkHashesValid = true;
389
+
390
+ for (const chunkInfo of manifest.chunks) {
391
+ const chunk = chunkByIndex.get(chunkInfo.index);
392
+
393
+ if (!chunk) {
394
+ errors.push(`Missing chunk at index ${chunkInfo.index}`);
395
+ allChunkHashesValid = false;
396
+ continue;
397
+ }
398
+
399
+ try {
400
+ // Compute hash of chunk content
401
+ const computedHash = await sha256StringHex(chunk.content);
402
+
403
+ if (computedHash !== chunkInfo.hash) {
404
+ errors.push(
405
+ `Chunk ${chunkInfo.index} hash mismatch: expected ${chunkInfo.hash}, computed ${computedHash}`
406
+ );
407
+ allChunkHashesValid = false;
408
+ }
409
+
410
+ // Verify size matches
411
+ if (chunk.content.length !== chunkInfo.size) {
412
+ errors.push(
413
+ `Chunk ${chunkInfo.index} size mismatch: expected ${chunkInfo.size}, got ${chunk.content.length}`
414
+ );
415
+ allChunkHashesValid = false;
416
+ }
417
+ } catch (error) {
418
+ errors.push(
419
+ `Failed to verify chunk ${chunkInfo.index}: ${error instanceof Error ? error.message : String(error)}`
420
+ );
421
+ allChunkHashesValid = false;
422
+ }
423
+ }
424
+
425
+ // Check for extra chunks not in manifest
426
+ for (const chunk of chunks) {
427
+ const inManifest = manifest.chunks.some((c) => c.index === chunk.info.index);
428
+ if (!inManifest) {
429
+ warnings.push(`Extra chunk at index ${chunk.info.index} not referenced in manifest`);
430
+ }
431
+ }
432
+
433
+ checks.chunkHashesValid = allChunkHashesValid;
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Verify Root Hash Presence
437
+ // ---------------------------------------------------------------------------
438
+
439
+ if (manifest.rootHash && manifest.rootHash.length > 0) {
440
+ checks.rootHashMatches = true;
441
+ } else {
442
+ errors.push("Manifest is missing rootHash");
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Verify Merkle Root Presence
447
+ // ---------------------------------------------------------------------------
448
+
449
+ if (manifest.merkleRoot && manifest.merkleRoot.length > 0) {
450
+ checks.merkleRootMatches = true;
451
+ } else {
452
+ errors.push("Manifest is missing merkleRoot");
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Additional Warnings
457
+ // ---------------------------------------------------------------------------
458
+
459
+ if (manifest.chunks.length === 0) {
460
+ warnings.push("Manifest has no chunks - trace data may be empty");
461
+ }
462
+
463
+ if (manifest.totalSpans === 0 && manifest.chunks.length > 0) {
464
+ warnings.push("Manifest reports 0 spans but has chunks");
465
+ }
466
+
467
+ // Determine overall validity
468
+ const valid =
469
+ checks.manifestHashValid &&
470
+ checks.chunkHashesValid &&
471
+ checks.rootHashMatches &&
472
+ checks.merkleRootMatches;
473
+
474
+ return {
475
+ valid,
476
+ errors,
477
+ warnings,
478
+ checks,
479
+ };
480
+ }
481
+
482
+ // =============================================================================
483
+ // BUNDLE RECONSTRUCTION
484
+ // =============================================================================
485
+
486
+ /**
487
+ * Reconstruct a trace bundle from manifest and chunks.
488
+ *
489
+ * This is the inverse operation of createManifest. It parses all chunk
490
+ * contents, merges spans and events, and reconstructs the complete
491
+ * TraceBundle.
492
+ *
493
+ * Note: The reconstructed bundle will use the publicView from the manifest
494
+ * and construct a privateRun from the chunk data.
495
+ *
496
+ * @param manifest - The manifest describing the trace
497
+ * @param chunks - All chunks referenced by the manifest
498
+ * @returns Promise resolving to the reconstructed TraceBundle
499
+ * @throws Error if required chunks are missing or corrupted
500
+ *
501
+ * @example
502
+ * ```typescript
503
+ * // Load manifest and chunks from storage
504
+ * const manifest = JSON.parse(await storage.read("manifest.json"));
505
+ * const chunks = await loadChunks(manifest.chunks);
506
+ *
507
+ * // Reconstruct the bundle
508
+ * const bundle = await reconstructBundleFromManifest(manifest, chunks);
509
+ *
510
+ * // Now you can access the full trace data
511
+ * console.log(`Reconstructed ${bundle.privateRun.events.length} events`);
512
+ * ```
513
+ */
514
+ export async function reconstructBundleFromManifest(
515
+ manifest: TraceManifest,
516
+ chunks: Chunk[]
517
+ ): Promise<TraceBundle> {
518
+ // Build chunk lookup by index
519
+ const chunkByIndex = new Map<number, Chunk>();
520
+ for (const chunk of chunks) {
521
+ chunkByIndex.set(chunk.info.index, chunk);
522
+ }
523
+
524
+ // Parse all chunks and merge spans/events
525
+ const allSpans: TraceSpan[] = [];
526
+ const allEvents: TraceEvent[] = [];
527
+
528
+ // Process chunks in order
529
+ const sortedChunkInfos = [...manifest.chunks].sort((a, b) => a.index - b.index);
530
+
531
+ for (const chunkInfo of sortedChunkInfos) {
532
+ const chunk = chunkByIndex.get(chunkInfo.index);
533
+
534
+ if (!chunk) {
535
+ throw new Error(`Missing chunk at index ${chunkInfo.index}`);
536
+ }
537
+
538
+ // Parse chunk content
539
+ const { spans, events } = parseChunkContent(chunk.content);
540
+
541
+ allSpans.push(...spans);
542
+ allEvents.push(...events);
543
+ }
544
+
545
+ // Sort spans by spanSeq and events by seq for proper ordering
546
+ allSpans.sort((a, b) => a.spanSeq - b.spanSeq);
547
+ allEvents.sort((a, b) => a.seq - b.seq);
548
+
549
+ // Compute the next sequence numbers
550
+ const nextSeq = allEvents.length > 0
551
+ ? Math.max(...allEvents.map(e => e.seq)) + 1
552
+ : 0;
553
+ const nextSpanSeq = allSpans.length > 0
554
+ ? Math.max(...allSpans.map(s => s.spanSeq)) + 1
555
+ : 0;
556
+
557
+ // Reconstruct the trace run
558
+ // Note: We need to derive rollingHash from events if possible, but that would
559
+ // require recomputing. For now, we store a placeholder and note that full
560
+ // verification would need the original rollingHash.
561
+ const privateRun: TraceRun = {
562
+ id: manifest.runId,
563
+ schemaVersion: manifest.formatVersion,
564
+ agentId: manifest.agentId,
565
+ status: manifest.publicView.status as "running" | "completed" | "failed" | "cancelled",
566
+ startedAt: manifest.startedAt,
567
+ endedAt: manifest.endedAt,
568
+ durationMs: manifest.durationMs,
569
+ events: allEvents,
570
+ spans: allSpans,
571
+ rollingHash: "", // Would need to be recomputed for full verification
572
+ rootHash: manifest.rootHash,
573
+ nextSeq,
574
+ nextSpanSeq,
575
+ };
576
+
577
+ // Build the complete bundle
578
+ const bundle: TraceBundle = {
579
+ formatVersion: manifest.formatVersion,
580
+ publicView: manifest.publicView,
581
+ privateRun,
582
+ merkleRoot: manifest.merkleRoot,
583
+ rootHash: manifest.rootHash,
584
+ };
585
+
586
+ // Add manifestHash if present
587
+ if (manifest.manifestHash !== undefined) {
588
+ bundle.manifestHash = manifest.manifestHash;
589
+ }
590
+
591
+ return bundle;
592
+ }
593
+
594
+ // =============================================================================
595
+ // CHUNK PATH UTILITIES
596
+ // =============================================================================
597
+
598
+ /**
599
+ * Get the storage path for a chunk.
600
+ *
601
+ * Returns the relative path where the chunk should be stored.
602
+ * The consumer is responsible for adding any compression suffix
603
+ * (e.g., ".gz" if compressed) and handling the actual storage.
604
+ *
605
+ * @param chunkInfo - Information about the chunk
606
+ * @returns The relative storage path for the chunk
607
+ *
608
+ * @example
609
+ * ```typescript
610
+ * const path = getChunkPath(chunk.info);
611
+ * // Returns: "chunks/abc123...def.json"
612
+ *
613
+ * // Consumer adds compression suffix if needed
614
+ * const storagePath = chunk.info.compression === "gzip"
615
+ * ? path + ".gz"
616
+ * : path;
617
+ * ```
618
+ */
619
+ export function getChunkPath(chunkInfo: ChunkInfo): string {
620
+ return `chunks/${chunkInfo.hash}.json`;
621
+ }
622
+
623
+ // =============================================================================
624
+ // CHUNK CONTENT PARSING
625
+ // =============================================================================
626
+
627
+ /**
628
+ * Parse chunk content from JSON string.
629
+ *
630
+ * Parses the serialized chunk content and returns the spans and events
631
+ * contained within. This function handles the internal chunk format.
632
+ *
633
+ * @param content - JSON string of chunk content
634
+ * @returns Object containing spans and events arrays
635
+ * @throws Error if content cannot be parsed or is malformed
636
+ *
637
+ * @example
638
+ * ```typescript
639
+ * const { spans, events } = parseChunkContent(chunk.content);
640
+ * console.log(`Chunk contains ${spans.length} spans and ${events.length} events`);
641
+ * ```
642
+ */
643
+ export function parseChunkContent(content: string): { spans: TraceSpan[]; events: TraceEvent[] } {
644
+ try {
645
+ const parsed = JSON.parse(content) as unknown;
646
+
647
+ // Validate parsed content has expected structure
648
+ if (typeof parsed !== "object" || parsed === null) {
649
+ throw new Error("Chunk content must be an object");
650
+ }
651
+
652
+ const obj = parsed as Record<string, unknown>;
653
+
654
+ if (!Array.isArray(obj.spans)) {
655
+ throw new Error("Chunk content must have a 'spans' array");
656
+ }
657
+
658
+ if (!Array.isArray(obj.events)) {
659
+ throw new Error("Chunk content must have an 'events' array");
660
+ }
661
+
662
+ // Type cast with validation
663
+ const spans = obj.spans as TraceSpan[];
664
+ const events = obj.events as TraceEvent[];
665
+
666
+ // Basic validation of spans
667
+ for (const span of spans) {
668
+ if (typeof span.id !== "string" || typeof span.spanSeq !== "number") {
669
+ throw new Error("Invalid span structure in chunk content");
670
+ }
671
+ }
672
+
673
+ // Basic validation of events
674
+ for (const event of events) {
675
+ if (typeof event.id !== "string" || typeof event.seq !== "number") {
676
+ throw new Error("Invalid event structure in chunk content");
677
+ }
678
+ }
679
+
680
+ return { spans, events };
681
+ } catch (error) {
682
+ if (error instanceof SyntaxError) {
683
+ throw new Error(`Invalid JSON in chunk content: ${error.message}`);
684
+ }
685
+ throw error;
686
+ }
687
+ }