retrace-sdk 0.16.7 → 0.16.9
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/config.js +1 -1
- package/dist/interceptors/anthropic.js +1 -0
- package/dist/interceptors/openai.js +1 -0
- package/dist/recorder.js +7 -0
- package/dist/telemetry.d.ts +7 -0
- package/dist/telemetry.js +36 -11
- package/dist/transport.d.ts +6 -3
- package/dist/transport.js +63 -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/config.js
CHANGED
|
@@ -63,7 +63,7 @@ export function configure(opts) {
|
|
|
63
63
|
if (config.enabled) {
|
|
64
64
|
void import("./interceptors/install.js").then((m) => m.ensureInterceptorsInstalled()).catch(() => { });
|
|
65
65
|
}
|
|
66
|
-
void import("./telemetry.js").then((m) => m.track("
|
|
66
|
+
void import("./telemetry.js").then((m) => m.track("sdk_init", { has_api_key: !!config.apiKey, enabled: config.enabled })).catch(() => { });
|
|
67
67
|
return config;
|
|
68
68
|
}
|
|
69
69
|
export function requireApiKey() {
|
|
@@ -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/recorder.js
CHANGED
|
@@ -184,6 +184,13 @@ export class TraceRecorder {
|
|
|
184
184
|
// lossy / capture_complete:false). Only a naturally-drained run produces a clean terminal.
|
|
185
185
|
...(opts?.terminatedEarly ? { terminated_early: true } : {}),
|
|
186
186
|
});
|
|
187
|
+
// Anonymous usage analytics (fire-and-forget; never throws): record success/failure + which
|
|
188
|
+
// provider families ran. No PII, no user content.
|
|
189
|
+
try {
|
|
190
|
+
const dict = this.builder.toDict();
|
|
191
|
+
void import("./telemetry.js").then((m) => m.trackRecord(String(data.status), dict.spans ?? [], dict.metadata ?? {})).catch(() => { });
|
|
192
|
+
}
|
|
193
|
+
catch { /* never affect the user's program */ }
|
|
187
194
|
// Restore the enclosing trace's fallback (e.g. the ambient init() recorder) instead of nulling.
|
|
188
195
|
setActiveRecorderFallback(this.prevFallback, this.prevFallbackSink);
|
|
189
196
|
this.prevFallback = null;
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
+
/** Coarse provider family from a model name (analytics only — never the key or content). */
|
|
2
|
+
export declare function providerOf(model?: string | null): string;
|
|
1
3
|
export declare function track(event: string, fields?: Record<string, string | number | boolean>): void;
|
|
4
|
+
/** Emit record_success / record_failure with anonymous, aggregate-only fields (span count, provider
|
|
5
|
+
* families used, whether the run was resumable / a cascade replay). Never throws. */
|
|
6
|
+
export declare function trackRecord(status: string, spans: Array<{
|
|
7
|
+
model?: string | null;
|
|
8
|
+
}>, metadata?: Record<string, unknown>): void;
|
package/dist/telemetry.js
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Anonymous,
|
|
2
|
+
* Anonymous, always-on diagnostic telemetry.
|
|
3
3
|
*
|
|
4
4
|
* Sends NO user code, API keys, prompts, responses, or personal information — only the SDK version,
|
|
5
|
-
* the runtime/OS family, an anonymous per-process id, and event/error *categories
|
|
6
|
-
* API's internal
|
|
7
|
-
* service="sdk-typescript"
|
|
5
|
+
* the runtime/OS family, an anonymous per-process id, and event/error *categories*. Events are POSTed
|
|
6
|
+
* to the Retrace API's internal ingest, which forwards them to PostHog (and the logging platform)
|
|
7
|
+
* under service="sdk-typescript" — so the SDK NEVER holds any analytics key (SDK → Retrace API →
|
|
8
|
+
* PostHog).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* Fire-and-forget: never blocks the user's program, never throws. Anonymous by construction (no PII,
|
|
11
|
+
* random per-process id) and intentionally NOT user-disableable.
|
|
10
12
|
*/
|
|
11
13
|
import os from "node:os";
|
|
12
14
|
import { getConfig } from "./config.js";
|
|
13
15
|
// Per-process random id — anonymous, not linked to any account, user, or machine identity.
|
|
14
16
|
const ANON_ID = Math.random().toString(16).slice(2, 18);
|
|
15
|
-
const DISABLED = new Set(["0", "false", "no", "off"]);
|
|
16
17
|
// Keep in sync with package.json version.
|
|
17
|
-
const SDK_VERSION = "0.16.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const SDK_VERSION = "0.16.9";
|
|
19
|
+
/** Coarse provider family from a model name (analytics only — never the key or content). */
|
|
20
|
+
export function providerOf(model) {
|
|
21
|
+
const m = (model || "").toLowerCase();
|
|
22
|
+
if (!m)
|
|
23
|
+
return "";
|
|
24
|
+
if (m.startsWith("gemini") || m.includes("google"))
|
|
25
|
+
return "google";
|
|
26
|
+
if (/^(gpt|o1|o3|o4|chatgpt|text-embedding|davinci)/.test(m))
|
|
27
|
+
return "openai";
|
|
28
|
+
if (m.includes("claude"))
|
|
29
|
+
return "anthropic";
|
|
30
|
+
return "other";
|
|
20
31
|
}
|
|
21
32
|
export function track(event, fields = {}) {
|
|
22
|
-
if (!enabled())
|
|
23
|
-
return;
|
|
24
33
|
try {
|
|
25
34
|
const base = (getConfig().baseUrl || "https://api.retraceai.tech").replace(/\/$/, "");
|
|
26
35
|
const body = JSON.stringify({
|
|
@@ -49,3 +58,19 @@ export function track(event, fields = {}) {
|
|
|
49
58
|
/* telemetry must never affect the user's program */
|
|
50
59
|
}
|
|
51
60
|
}
|
|
61
|
+
/** Emit record_success / record_failure with anonymous, aggregate-only fields (span count, provider
|
|
62
|
+
* families used, whether the run was resumable / a cascade replay). Never throws. */
|
|
63
|
+
export function trackRecord(status, spans, metadata = {}) {
|
|
64
|
+
try {
|
|
65
|
+
const provs = Array.from(new Set(spans.map((s) => providerOf(s.model)).filter(Boolean)));
|
|
66
|
+
track(status === "completed" ? "record_success" : "record_failure", {
|
|
67
|
+
spans: spans.length,
|
|
68
|
+
providers: provs.join(",") || "none",
|
|
69
|
+
resumable: !!metadata._resumable,
|
|
70
|
+
cascade: !!metadata._cascade_replay,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* never throw */
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -69,13 +69,16 @@ 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
74
|
private backlog;
|
|
75
75
|
private readonly backlogCap;
|
|
76
76
|
private dropped;
|
|
77
|
+
private entry;
|
|
77
78
|
send(eventType: string, data: Record<string, unknown>): void;
|
|
78
|
-
|
|
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;
|
|
79
82
|
/** POST one complete trace payload. Returns "ok" | "reject" (non-retryable 4xx) | "retry"
|
|
80
83
|
* (429 / 5xx / network after retries exhausted — caller should retain the payload). */
|
|
81
84
|
private upload;
|
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,40 +347,82 @@ 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();
|
|
352
358
|
// Bounded offline backlog (parity with WSTransport's queue + the Python HTTPTransport): a trace
|
|
353
359
|
// that fails to upload after exhausting transient retries is RETAINED here and retried on a later
|
|
354
360
|
// flush instead of being silently dropped. Bounded so a long outage can't grow memory unbounded.
|
|
355
361
|
backlog = [];
|
|
356
362
|
backlogCap = 1000;
|
|
357
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
|
+
}
|
|
358
372
|
send(eventType, data) {
|
|
359
373
|
if (eventType === "trace_started") {
|
|
360
|
-
|
|
374
|
+
const id = data.id;
|
|
375
|
+
if (!id)
|
|
376
|
+
return;
|
|
377
|
+
const e = this.entry(id);
|
|
378
|
+
e.data = { ...e.data, ...data };
|
|
361
379
|
}
|
|
362
|
-
else if (eventType === "span_started"
|
|
363
|
-
|
|
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 });
|
|
386
|
+
}
|
|
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 });
|
|
364
393
|
}
|
|
365
394
|
else if (eventType === "trace_ended") {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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);
|
|
369
402
|
}
|
|
370
403
|
}
|
|
371
|
-
|
|
404
|
+
/** Flush one trace (on its trace_ended) or, when no id is given, every pending trace (shutdown). */
|
|
405
|
+
async flush(traceId) {
|
|
372
406
|
// Opportunistically retry anything stranded by a previous outage first.
|
|
373
407
|
if (this.backlog.length)
|
|
374
408
|
await this.drainBacklog();
|
|
375
|
-
if (
|
|
409
|
+
if (traceId !== undefined) {
|
|
410
|
+
await this.flushOne(traceId);
|
|
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)
|
|
376
419
|
return;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
this.spans
|
|
382
|
-
// On a transient failure, RETAIN the trace in the bounded backlog instead of dropping it
|
|
383
|
-
// previous code cleared the buffer unconditionally, losing the trace under sustained 429/5xx.
|
|
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.
|
|
384
426
|
if ((await this.upload(payload)) === "retry")
|
|
385
427
|
this.enqueueBacklog(payload);
|
|
386
428
|
}
|
|
@@ -438,9 +480,9 @@ export class HTTPTransport {
|
|
|
438
480
|
this.backlog.shift(); // "ok" (delivered) or "reject" (unrecoverable) → remove
|
|
439
481
|
}
|
|
440
482
|
}
|
|
441
|
-
buildSpans() {
|
|
483
|
+
buildSpans(spans) {
|
|
442
484
|
const merged = new Map();
|
|
443
|
-
for (const ev of
|
|
485
|
+
for (const ev of spans) {
|
|
444
486
|
const { _event, ...rest } = ev;
|
|
445
487
|
const id = rest.id;
|
|
446
488
|
if (_event === "span_started") {
|
|
@@ -460,7 +502,7 @@ export class HTTPTransport {
|
|
|
460
502
|
await this.flush();
|
|
461
503
|
}
|
|
462
504
|
hasPendingData() {
|
|
463
|
-
return this.
|
|
505
|
+
return this.pending.size > 0 || this.backlog.length > 0;
|
|
464
506
|
}
|
|
465
507
|
}
|
|
466
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.9",
|
|
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",
|