retrace-sdk 0.16.6 → 0.16.8

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/yash1511-bogam/retrace-sdk/main/assets/banner.gif" alt="Retrace" width="480" />
3
+ <img src="https://raw.githubusercontent.com/Retraceai-tech/retrace-sdk/main/assets/banner.gif" alt="Retrace" width="480" />
4
4
 
5
5
  ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
6
6
 
@@ -207,7 +207,7 @@ Typed errors for auth failures, credit exhaustion, rate limiting, and enforcemen
207
207
  ## Links
208
208
 
209
209
  - [Documentation](https://docs.retraceai.tech)
210
- - [GitHub](https://github.com/yash1511-bogam/retrace-sdk)
210
+ - [GitHub](https://github.com/Retraceai-tech/retrace-sdk)
211
211
  - [npm](https://www.npmjs.com/package/retrace-sdk)
212
212
 
213
213
  ## License
@@ -16,7 +16,7 @@ function shortHash(input) {
16
16
  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
17
17
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
18
18
  const n = 4294967296 * (2097151 & h2) + (h1 >>> 0);
19
- return n.toString(16).padStart(14, "0");
19
+ return n.toString(16).padStart(16, "0");
20
20
  };
21
21
  return (pass(0) + pass(0x9e3779b9)).slice(0, 32);
22
22
  }
@@ -219,5 +219,6 @@ export function uninstallAnthropicInterceptor() {
219
219
  proto.create = originalCreate;
220
220
  }).catch(() => { });
221
221
  installed = false;
222
+ installStarted = false; // allow a subsequent installAnthropicInterceptor() to re-patch (was a no-op)
222
223
  onSpanCallback = null;
223
224
  }
@@ -307,5 +307,6 @@ export function uninstallOpenAIInterceptor() {
307
307
  proto.create = originalCreate;
308
308
  }).catch(() => { });
309
309
  installed = false;
310
+ installStarted = false; // allow a subsequent installOpenAIInterceptor() to re-patch (was a no-op)
310
311
  onSpanCallback = null;
311
312
  }
@@ -69,10 +69,24 @@ export declare class WSTransport implements Transport {
69
69
  private flushViaHttp;
70
70
  }
71
71
  export declare class HTTPTransport implements Transport {
72
- private traceData;
73
- private spans;
72
+ private pending;
73
+ private spanTrace;
74
+ private backlog;
75
+ private readonly backlogCap;
76
+ private dropped;
77
+ private entry;
74
78
  send(eventType: string, data: Record<string, unknown>): void;
75
- flush(): Promise<void>;
79
+ /** Flush one trace (on its trace_ended) or, when no id is given, every pending trace (shutdown). */
80
+ flush(traceId?: string): Promise<void>;
81
+ private flushOne;
82
+ /** POST one complete trace payload. Returns "ok" | "reject" (non-retryable 4xx) | "retry"
83
+ * (429 / 5xx / network after retries exhausted — caller should retain the payload). */
84
+ private upload;
85
+ /** Retain a failed trace for a later retry; bounded — drop + count the oldest on overflow. */
86
+ private enqueueBacklog;
87
+ /** Retry previously-failed traces oldest-first; stop at the first still-failing one so a server
88
+ * that is still down isn't hammered. */
89
+ private drainBacklog;
76
90
  private buildSpans;
77
91
  close(): void;
78
92
  /** HTTP is already the one-shot channel — just drain. */
package/dist/transport.js CHANGED
@@ -87,7 +87,7 @@ export class WSTransport {
87
87
  // (no-op on runtimes without `_socket`, e.g. the global WebSocket). Graceful shutdown still
88
88
  // drains via flush()/beforeExit.
89
89
  socket?._socket?.unref?.();
90
- socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey }));
90
+ socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey, resumable: getConfig().allowRemoteReplay }));
91
91
  });
92
92
  socket.addEventListener("message", (ev) => {
93
93
  try {
@@ -347,31 +347,90 @@ export class WSTransport {
347
347
  }
348
348
  }
349
349
  export class HTTPTransport {
350
- traceData = null;
351
- spans = [];
350
+ // Buffer per-trace so concurrent traces in one process (parallel requests / Promise.all) never
351
+ // clobber each other. The previous single traceData/spans pair merged interleaved traces into one
352
+ // corrupt POST (trace B's trace_started overwrote A; A's flush sent B's data + both sets of spans).
353
+ // Keyed by trace id; spans route by their trace_id.
354
+ pending = new Map();
355
+ // span_ended events carry no trace_id (parity with the WS protocol), so remember each span's trace
356
+ // from its span_started to route the end event to the right per-trace buffer.
357
+ spanTrace = new Map();
358
+ // Bounded offline backlog (parity with WSTransport's queue + the Python HTTPTransport): a trace
359
+ // that fails to upload after exhausting transient retries is RETAINED here and retried on a later
360
+ // flush instead of being silently dropped. Bounded so a long outage can't grow memory unbounded.
361
+ backlog = [];
362
+ backlogCap = 1000;
363
+ dropped = 0;
364
+ entry(traceId) {
365
+ let e = this.pending.get(traceId);
366
+ if (!e) {
367
+ e = { data: {}, spans: [] };
368
+ this.pending.set(traceId, e);
369
+ }
370
+ return e;
371
+ }
352
372
  send(eventType, data) {
353
373
  if (eventType === "trace_started") {
354
- this.traceData = data;
374
+ const id = data.id;
375
+ if (!id)
376
+ return;
377
+ const e = this.entry(id);
378
+ e.data = { ...e.data, ...data };
379
+ }
380
+ else if (eventType === "span_started") {
381
+ const tid = data.trace_id;
382
+ if (!tid)
383
+ return;
384
+ this.spanTrace.set(data.id, tid);
385
+ this.entry(tid).spans.push({ ...data, _event: eventType });
355
386
  }
356
- else if (eventType === "span_started" || eventType === "span_ended") {
357
- this.spans.push({ ...data, _event: eventType });
387
+ else if (eventType === "span_ended") {
388
+ // span_ended has no trace_id — route by the span id captured at span_started.
389
+ const tid = data.trace_id || this.spanTrace.get(data.id);
390
+ if (!tid)
391
+ return;
392
+ this.entry(tid).spans.push({ ...data, _event: eventType });
358
393
  }
359
394
  else if (eventType === "trace_ended") {
360
- if (this.traceData)
361
- Object.assign(this.traceData, data);
362
- void this.flush();
395
+ const id = data.id;
396
+ if (!id)
397
+ return;
398
+ const e = this.pending.get(id);
399
+ if (e)
400
+ Object.assign(e.data, data);
401
+ void this.flush(id);
363
402
  }
364
403
  }
365
- async flush() {
366
- if (!this.traceData)
404
+ /** Flush one trace (on its trace_ended) or, when no id is given, every pending trace (shutdown). */
405
+ async flush(traceId) {
406
+ // Opportunistically retry anything stranded by a previous outage first.
407
+ if (this.backlog.length)
408
+ await this.drainBacklog();
409
+ if (traceId !== undefined) {
410
+ await this.flushOne(traceId);
367
411
  return;
412
+ }
413
+ for (const id of [...this.pending.keys()])
414
+ await this.flushOne(id);
415
+ }
416
+ async flushOne(traceId) {
417
+ const e = this.pending.get(traceId);
418
+ if (!e)
419
+ return;
420
+ // Remove first so a concurrent flush (e.g. trace_ended then shutdown drain) can't double-send.
421
+ this.pending.delete(traceId);
422
+ for (const ev of e.spans)
423
+ this.spanTrace.delete(ev.id);
424
+ const payload = JSON.stringify({ ...e.data, spans: this.buildSpans(e.spans) });
425
+ // On a transient failure, RETAIN the trace in the bounded backlog instead of dropping it.
426
+ if ((await this.upload(payload)) === "retry")
427
+ this.enqueueBacklog(payload);
428
+ }
429
+ /** POST one complete trace payload. Returns "ok" | "reject" (non-retryable 4xx) | "retry"
430
+ * (429 / 5xx / network after retries exhausted — caller should retain the payload). */
431
+ async upload(payload) {
368
432
  const cfg = getConfig();
369
433
  const url = `${cfg.baseUrl}/api/v1/traces`;
370
- const body = { ...this.traceData, spans: this.buildSpans() };
371
- const payload = JSON.stringify(body);
372
- // Clear first so a concurrent flush (e.g. trace_ended then shutdown drain) can't double-send.
373
- this.traceData = null;
374
- this.spans = [];
375
434
  // Retry up to 3 times with exponential backoff; awaited so shutdown can drain it.
376
435
  for (let n = 1; n <= 3; n++) {
377
436
  try {
@@ -381,13 +440,13 @@ export class HTTPTransport {
381
440
  body: payload,
382
441
  });
383
442
  if (res.ok)
384
- return;
443
+ return "ok";
385
444
  const txt = await res.text().catch(() => "");
386
445
  // 4xx (except 429) is a client/payload error that won't succeed on retry — surface
387
446
  // it loudly and stop, rather than silently dropping the trace.
388
447
  if (res.status < 500 && res.status !== 429) {
389
448
  console.error(`[retrace] trace upload rejected (HTTP ${res.status}): ${txt.slice(0, 300)}`);
390
- return;
449
+ return "reject";
391
450
  }
392
451
  // 5xx / 429 — transient; retry.
393
452
  if (n === 3)
@@ -400,10 +459,30 @@ export class HTTPTransport {
400
459
  if (n < 3)
401
460
  await new Promise((r) => setTimeout(r, 1000 * n));
402
461
  }
462
+ return "retry";
463
+ }
464
+ /** Retain a failed trace for a later retry; bounded — drop + count the oldest on overflow. */
465
+ enqueueBacklog(payload) {
466
+ this.backlog.push(payload);
467
+ while (this.backlog.length > this.backlogCap) {
468
+ this.backlog.shift();
469
+ this.dropped++;
470
+ console.error(`[retrace] offline buffer full (cap ${this.backlogCap}) — dropped oldest trace (total dropped: ${this.dropped})`);
471
+ }
472
+ }
473
+ /** Retry previously-failed traces oldest-first; stop at the first still-failing one so a server
474
+ * that is still down isn't hammered. */
475
+ async drainBacklog() {
476
+ while (this.backlog.length) {
477
+ const r = await this.upload(this.backlog[0]);
478
+ if (r === "retry")
479
+ break; // still failing — keep the rest for next time
480
+ this.backlog.shift(); // "ok" (delivered) or "reject" (unrecoverable) → remove
481
+ }
403
482
  }
404
- buildSpans() {
483
+ buildSpans(spans) {
405
484
  const merged = new Map();
406
- for (const ev of this.spans) {
485
+ for (const ev of spans) {
407
486
  const { _event, ...rest } = ev;
408
487
  const id = rest.id;
409
488
  if (_event === "span_started") {
@@ -423,7 +502,7 @@ export class HTTPTransport {
423
502
  await this.flush();
424
503
  }
425
504
  hasPendingData() {
426
- return this.traceData !== null || this.spans.length > 0;
505
+ return this.pending.size > 0 || this.backlog.length > 0;
427
506
  }
428
507
  }
429
508
  export function createTransport(mode = "auto") {
package/dist/utils.js CHANGED
@@ -6,7 +6,6 @@ export function genId() {
6
6
  return globalThis.crypto.randomUUID();
7
7
  }
8
8
  const _textEncoder = new TextEncoder();
9
- const _textDecoder = new TextDecoder();
10
9
  export function nowIso() {
11
10
  return new Date().toISOString();
12
11
  }
@@ -34,17 +33,47 @@ export function shouldSample(rate, seed, key) {
34
33
  }
35
34
  return ((hash >>> 0) / 4294967296) < rate;
36
35
  }
36
+ /** Truncate a string to at most `maxBytes` UTF-8 bytes WITHOUT splitting a multi-byte codepoint, so
37
+ * there's no garbled/replacement-char tail. Iterates whole code points (via the string iterator). */
38
+ function sliceStringToBytes(s, maxBytes) {
39
+ if (maxBytes <= 0)
40
+ return "";
41
+ if (_textEncoder.encode(s).length <= maxBytes)
42
+ return s;
43
+ let out = "";
44
+ let n = 0;
45
+ for (const ch of s) {
46
+ const b = _textEncoder.encode(ch).length;
47
+ if (n + b > maxBytes)
48
+ break;
49
+ out += ch;
50
+ n += b;
51
+ }
52
+ return out;
53
+ }
37
54
  export function truncateJson(obj, maxBytes = 10240) {
55
+ let s;
38
56
  try {
39
- const s = JSON.stringify(obj);
40
- const bytes = _textEncoder.encode(s);
41
- if (bytes.length <= maxBytes)
42
- return obj;
43
- return JSON.parse(_textDecoder.decode(bytes.subarray(0, maxBytes)));
57
+ s = JSON.stringify(obj);
44
58
  }
45
59
  catch {
46
- return String(obj).slice(0, maxBytes);
60
+ s = undefined;
47
61
  }
62
+ // Not JSON-serializable (circular ref, BigInt, …): char-safe string slice, not a broken parse.
63
+ if (typeof s !== "string")
64
+ return sliceStringToBytes(String(obj), maxBytes);
65
+ const byteLen = _textEncoder.encode(s).length;
66
+ if (byteLen <= maxBytes)
67
+ return obj;
68
+ // Over the limit. Return a self-describing, VALID marker with a UTF-8-safe preview. The old code
69
+ // byte-sliced the JSON mid-codepoint and JSON.parse()'d the fragment — which almost always threw,
70
+ // falling back to String(obj) = "[object Object]" for any object (total data loss) and a garbled
71
+ // tail for strings. `wasTruncated` still flags this so the server refuses to byte-replay it.
72
+ return {
73
+ _truncated: true,
74
+ _original_bytes: byteLen,
75
+ _preview: sliceStringToBytes(s, Math.max(0, maxBytes - 64)),
76
+ };
48
77
  }
49
78
  /** True if truncateJson(obj, maxBytes) would drop bytes. Used to flag a span's output as truncated
50
79
  * so the server refuses to byte-replay it (the replayed value would differ from the original). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "description": "The execution replay engine for AI agents. Record, replay, fork, and share agent executions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "author": "Yash Bogam",
29
29
  "repository": {
30
30
  "type": "git",
31
- "url": "https://github.com/yash1511-bogam/retrace-sdk",
31
+ "url": "https://github.com/Retraceai-tech/retrace-sdk",
32
32
  "directory": "packages/sdk-typescript"
33
33
  },
34
34
  "homepage": "https://retraceai.tech/docs/sdk-typescript",