retrace-sdk 0.11.3 → 0.11.4

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.
@@ -1,5 +1,5 @@
1
1
  import { SpanType } from "../trace.js";
2
- import { genId, nowIso, truncateJson } from "../utils.js";
2
+ import { genId, nowIso, truncateJson, wasTruncated } from "../utils.js";
3
3
  import { isReplaying, consumeCassetteEntry } from "../replay.js";
4
4
  import { emitAnthropicToolCalls, emitAnthropicToolResults, parseToolArgs, resetToolResultDedup, extractToolSchemas, extractSamplingParams } from "./tool-spans.js";
5
5
  import { dispatchRegisterOpenSpan, dispatchUnregisterOpenSpan } from "./_dispatch.js";
@@ -23,15 +23,18 @@ function calcCost(model, inputTokens, outputTokens) {
23
23
  }
24
24
  let originalCreate = null;
25
25
  let installed = false;
26
+ // Set SYNCHRONOUSLY before the async import() so a second concurrent install can't double-wrap the
27
+ // prototype. (`installed` is set inside the .then() and is therefore too late to guard the race.)
28
+ let installStarted = false;
26
29
  let onSpanCallback = null;
27
30
  export function installAnthropicInterceptor(onSpan) {
28
- if (installed) {
29
- onSpanCallback = onSpan;
30
- resetToolResultDedup();
31
- return;
32
- }
31
+ // Always refresh the active callback; the prototype PATCH must happen at most once (a synchronous
32
+ // guard so two concurrent installs can't both patch and double-wrap create() → doubled spans).
33
33
  onSpanCallback = onSpan;
34
34
  resetToolResultDedup();
35
+ if (installStarted)
36
+ return;
37
+ installStarted = true;
35
38
  import("@anthropic-ai/sdk").then((anthropicMod) => {
36
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
40
  const mod = anthropicMod;
@@ -45,7 +48,7 @@ export function installAnthropicInterceptor(onSpan) {
45
48
  originalCreate = proto.create;
46
49
  proto.create = createPatchedCreate();
47
50
  installed = true;
48
- }).catch(() => { });
51
+ }).catch(() => { installStarted = false; });
49
52
  }
50
53
  function createPatchedCreate() {
51
54
  return async function (...args) {
@@ -112,7 +115,7 @@ function createPatchedCreate() {
112
115
  input_tokens: inputTokens, output_tokens: outputTokens,
113
116
  cost: calcCost(model, inputTokens, outputTokens),
114
117
  duration_ms: durationMs, started_at: startedAt, ended_at: nowIso(),
115
- metadata: { streaming: true, ...(reason === "partial" ? { partial: true } : {}), ...spanMeta },
118
+ metadata: { streaming: true, ...(reason === "partial" ? { partial: true } : {}), ...(wasTruncated(output) ? { truncated: true } : {}), ...spanMeta },
116
119
  };
117
120
  onSpanCallback?.(span);
118
121
  if (onSpanCallback && reason === "complete") {
@@ -176,7 +179,7 @@ function createPatchedCreate() {
176
179
  input_tokens: inputTokens, output_tokens: outputTokens,
177
180
  cost: calcCost(model, inputTokens, outputTokens),
178
181
  duration_ms: durationMs, started_at: startedAt, ended_at: nowIso(),
179
- ...(Object.keys(spanMeta).length ? { metadata: spanMeta } : {}),
182
+ ...(Object.keys(spanMeta).length || wasTruncated(output) ? { metadata: { ...spanMeta, ...(wasTruncated(output) ? { truncated: true } : {}) } } : {}),
180
183
  };
181
184
  onSpanCallback?.(span);
182
185
  // Auto-capture tool usage (tool_use blocks in response, tool_result blocks in input).
@@ -1,5 +1,5 @@
1
1
  import { SpanType } from "../trace.js";
2
- import { genId, nowIso, truncateJson } from "../utils.js";
2
+ import { genId, nowIso, truncateJson, wasTruncated } from "../utils.js";
3
3
  import { isReplaying, consumeCassetteEntry } from "../replay.js";
4
4
  import { getConfig } from "../config.js";
5
5
  import { RetraceRateLimitError, RetraceAuthError, RetraceConnectionError } from "../errors.js";
@@ -61,15 +61,19 @@ function calcCost(model, inputTokens, outputTokens) {
61
61
  }
62
62
  let originalCreate = null;
63
63
  let installed = false;
64
+ // Set SYNCHRONOUSLY before the async import() so a second concurrent install can't double-wrap the
65
+ // prototype. (`installed` is set inside the .then() and is therefore too late to guard the race.)
66
+ let installStarted = false;
64
67
  let onSpanCallback = null;
65
68
  export function installOpenAIInterceptor(onSpan) {
66
- if (installed) {
67
- onSpanCallback = onSpan;
68
- resetToolResultDedup();
69
- return;
70
- }
69
+ // Always refresh the active callback; the prototype PATCH must happen at most once. The guard is
70
+ // a synchronous flag set before import() so two concurrent installs (e.g. two recorders starting
71
+ // before "openai" resolves) can't both patch and double-wrap create() → doubled spans/billing.
71
72
  onSpanCallback = onSpan;
72
73
  resetToolResultDedup();
74
+ if (installStarted)
75
+ return;
76
+ installStarted = true;
73
77
  import("openai").then((openaiMod) => {
74
78
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
79
  const mod = openaiMod;
@@ -90,7 +94,7 @@ export function installOpenAIInterceptor(onSpan) {
90
94
  originalCreate = proto.create;
91
95
  proto.create = createPatchedCreate();
92
96
  installed = true;
93
- }).catch(() => { });
97
+ }).catch(() => { installStarted = false; });
94
98
  }
95
99
  function createPatchedCreate() {
96
100
  return async function (...args) {
@@ -172,7 +176,7 @@ function createPatchedCreate() {
172
176
  input_tokens: inputTokens, output_tokens: outputTokens,
173
177
  cost: calcCost(model, inputTokens, outputTokens),
174
178
  duration_ms: durationMs, started_at: startedAt, ended_at: nowIso(),
175
- metadata: { streaming: true, ...(reason === "partial" ? { partial: true } : {}), ...(toolSchemas ? { tool_schemas: toolSchemas } : {}), ...(sampling ? { sampling } : {}) },
179
+ metadata: { streaming: true, ...(reason === "partial" ? { partial: true } : {}), ...(wasTruncated(output) ? { truncated: true } : {}), ...(toolSchemas ? { tool_schemas: toolSchemas } : {}), ...(sampling ? { sampling } : {}) },
176
180
  };
177
181
  onSpanCallback?.(span);
178
182
  if (onSpanCallback && reason === "complete") {
@@ -250,7 +254,7 @@ function createPatchedCreate() {
250
254
  duration_ms: durationMs, started_at: startedAt, ended_at: nowIso(),
251
255
  ...(tokenIds?.length ? { token_ids: tokenIds } : {}),
252
256
  ...(logprobValues?.length ? { logprobs: logprobValues } : {}),
253
- ...(Object.keys(spanMetadata).length ? { metadata: spanMetadata } : {}),
257
+ ...(Object.keys(spanMetadata).length || wasTruncated(output) ? { metadata: { ...spanMetadata, ...(wasTruncated(output) ? { truncated: true } : {}) } } : {}),
254
258
  };
255
259
  onSpanCallback?.(span);
256
260
  // Auto-capture tool usage: tool_result spans from the fed-back tool messages (deduped),
@@ -10,6 +10,9 @@ export interface RecordOptions {
10
10
  sessionId?: string;
11
11
  /** When set, spans emitted before this span ID is encountered are suppressed (pre-fork filtering). */
12
12
  forkPointSpanId?: string;
13
+ /** 0-based ordinal of the fork-point span among the original ordered spans. Suppression is
14
+ * positional: spans with counter <= index are suppressed, emission starts at index+1. */
15
+ forkPointIndex?: number;
13
16
  }
14
17
  export declare class TraceRecorder {
15
18
  private builder;
@@ -19,6 +22,7 @@ export declare class TraceRecorder {
19
22
  private prevFallback;
20
23
  private prevFallbackSink;
21
24
  private forkPointSpanId;
25
+ private forkPointIndex;
22
26
  private forkPointReached;
23
27
  private spanCounter;
24
28
  output: unknown;
package/dist/recorder.js CHANGED
@@ -55,6 +55,7 @@ export class TraceRecorder {
55
55
  prevFallback = null;
56
56
  prevFallbackSink = null;
57
57
  forkPointSpanId;
58
+ forkPointIndex;
58
59
  forkPointReached = false;
59
60
  spanCounter = 0;
60
61
  output = undefined;
@@ -63,8 +64,10 @@ export class TraceRecorder {
63
64
  this.builder = new TraceBuilder();
64
65
  this.transport = getSharedTransport();
65
66
  this.forkPointSpanId = opts?.forkPointSpanId;
66
- // If no fork point specified, all spans pass through
67
- this.forkPointReached = !opts?.forkPointSpanId;
67
+ this.forkPointIndex = opts?.forkPointIndex;
68
+ // Suppress pre-fork spans only when BOTH a fork point and its positional index are known;
69
+ // otherwise (normal recording, or a fork command without an index) emit everything.
70
+ this.forkPointReached = !opts?.forkPointSpanId || opts?.forkPointIndex === undefined;
68
71
  const cfg = getConfig();
69
72
  if (cfg.projectId)
70
73
  this.builder.setProjectId(cfg.projectId);
@@ -160,12 +163,13 @@ export class TraceRecorder {
160
163
  }
161
164
  addSpan(span) {
162
165
  this.spanCounter++;
163
- // Fork point filtering: skip spans until the fork point is reached.
164
- // The server copies pre-fork spans; the SDK only emits from fork point onward.
166
+ // Fork-point filtering: during cascade replay suppress the pre-fork spans (the server already
167
+ // has them / they replay from the cassette) and emit only from the fork point onward. The fork
168
+ // point is the (forkPointIndex)-th span (0-based), i.e. the (index+1)-th counted here, so
169
+ // suppress while spanCounter <= index and emit once spanCounter > index. (Previously this
170
+ // compared spanCounter >= 1, which is always true after the increment ⇒ zero suppression.)
165
171
  if (!this.forkPointReached) {
166
- if (this.forkPointSpanId && this.spanCounter >= 1) {
167
- // Use span counter as proxy — the Nth span corresponds to the fork point index.
168
- // Mark as reached so all subsequent spans pass through.
172
+ if (this.forkPointIndex !== undefined && this.spanCounter > this.forkPointIndex) {
169
173
  this.forkPointReached = true;
170
174
  }
171
175
  else {
package/dist/resume.d.ts CHANGED
@@ -12,6 +12,9 @@ export interface ResumeCommand {
12
12
  traceId: string;
13
13
  traceName: string;
14
14
  forkPointSpanId: string;
15
+ /** 0-based ordinal of the fork-point span among the original ordered spans. Pre-fork spans
16
+ * (counter <= index) are suppressed on re-exec; the server already has them. */
17
+ forkPointIndex?: number;
15
18
  modifiedInput: unknown;
16
19
  originalArgs?: unknown[];
17
20
  }
package/dist/resume.js CHANGED
@@ -32,6 +32,7 @@ export async function handleResume(command) {
32
32
  _cascade_replay: true,
33
33
  },
34
34
  forkPointSpanId: command.forkPointSpanId,
35
+ forkPointIndex: command.forkPointIndex,
35
36
  });
36
37
  recorder.start(`Fork: ${command.traceName}`, command.modifiedInput);
37
38
  // Determine args for re-execution
@@ -59,6 +60,7 @@ export function parseResumeMessage(msg) {
59
60
  traceId: msg.data.traceId,
60
61
  traceName: msg.data.traceName,
61
62
  forkPointSpanId: msg.data.forkPointSpanId,
63
+ forkPointIndex: msg.data.forkPointIndex,
62
64
  modifiedInput: msg.data.modifiedInput,
63
65
  originalArgs: msg.data.originalArgs,
64
66
  };
package/dist/utils.d.ts CHANGED
@@ -8,6 +8,9 @@ export declare function utcNow(): Date;
8
8
  */
9
9
  export declare function shouldSample(rate: number, seed?: string, key?: string): boolean;
10
10
  export declare function truncateJson(obj: unknown, maxBytes?: number): unknown;
11
+ /** True if truncateJson(obj, maxBytes) would drop bytes. Used to flag a span's output as truncated
12
+ * so the server refuses to byte-replay it (the replayed value would differ from the original). */
13
+ export declare function wasTruncated(obj: unknown, maxBytes?: number): boolean;
11
14
  /** Configure per-span-type truncation limits. */
12
15
  export declare function setTruncationLimits(limits: Record<string, number>): void;
13
16
  /** Get the truncation limit for a given span type. */
package/dist/utils.js CHANGED
@@ -40,6 +40,16 @@ export function truncateJson(obj, maxBytes = 10240) {
40
40
  return String(obj).slice(0, maxBytes);
41
41
  }
42
42
  }
43
+ /** True if truncateJson(obj, maxBytes) would drop bytes. Used to flag a span's output as truncated
44
+ * so the server refuses to byte-replay it (the replayed value would differ from the original). */
45
+ export function wasTruncated(obj, maxBytes = 10240) {
46
+ try {
47
+ return Buffer.byteLength(JSON.stringify(obj)) > maxBytes;
48
+ }
49
+ catch {
50
+ return String(obj).length > maxBytes;
51
+ }
52
+ }
43
53
  /** Default per-span-type truncation limits (bytes). */
44
54
  const DEFAULT_TRUNCATION_LIMITS = {
45
55
  llm_call: 51200, // 50KB — LLM prompts can be large
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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",