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 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
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("configure")).catch(() => { });
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;
@@ -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, opt-out diagnostic telemetry.
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* to the Retrace
6
- * API's internal log ingest (forwarded to the centralized logging platform under
7
- * service="sdk-typescript"). Fire-and-forget; never blocks, never throws.
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
- * Disable entirely with RETRACE_TELEMETRY=0 (also accepts false/no/off).
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.5";
18
- function enabled() {
19
- return !DISABLED.has((process.env.RETRACE_TELEMETRY ?? "1").trim().toLowerCase());
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
+ }
@@ -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 traceData;
73
- private spans;
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
- 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;
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
- 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();
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
- 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 };
361
379
  }
362
- else if (eventType === "span_started" || eventType === "span_ended") {
363
- this.spans.push({ ...data, _event: eventType });
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
- if (this.traceData)
367
- Object.assign(this.traceData, data);
368
- 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);
369
402
  }
370
403
  }
371
- async flush() {
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 (!this.traceData)
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
- const body = { ...this.traceData, spans: this.buildSpans() };
378
- const payload = JSON.stringify(body);
379
- // Clear first so a concurrent flush (e.g. trace_ended then shutdown drain) can't double-send.
380
- this.traceData = null;
381
- this.spans = [];
382
- // On a transient failure, RETAIN the trace in the bounded backlog instead of dropping it — the
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 this.spans) {
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.traceData !== null || this.spans.length > 0 || this.backlog.length > 0;
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
- 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.7",
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/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",