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 +2 -2
- package/dist/enforcement.js +1 -1
- package/dist/interceptors/anthropic.js +1 -0
- package/dist/interceptors/openai.js +1 -0
- package/dist/transport.d.ts +17 -3
- package/dist/transport.js +100 -21
- package/dist/utils.js +36 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="https://raw.githubusercontent.com/
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Retraceai-tech/retrace-sdk/main/assets/banner.gif" alt="Retrace" width="480" />
|
|
4
4
|
|
|
5
5
|

|
|
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/
|
|
210
|
+
- [GitHub](https://github.com/Retraceai-tech/retrace-sdk)
|
|
211
211
|
- [npm](https://www.npmjs.com/package/retrace-sdk)
|
|
212
212
|
|
|
213
213
|
## License
|
package/dist/enforcement.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
|
73
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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",
|