retrace-sdk 0.14.0 → 0.16.0
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/dist/cassette.d.ts +7 -0
- package/dist/cassette.js +12 -9
- package/dist/config.js +18 -14
- package/dist/enforcement.d.ts +11 -0
- package/dist/enforcement.js +22 -13
- package/dist/interceptors/anthropic.js +4 -2
- package/dist/interceptors/gemini.js +5 -2
- package/dist/interceptors/openai.js +4 -2
- package/dist/pricing.d.ts +14 -0
- package/dist/pricing.js +16 -0
- package/dist/telemetry.js +1 -1
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +132 -75
- package/dist/utils.js +11 -5
- package/package.json +2 -2
package/dist/cassette.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden cassette writer for CI regression replay (Phase 4b).
|
|
3
|
+
*
|
|
4
|
+
* After recording a run, write its cassette to a file you commit to your repo; `retrace ci replay`
|
|
5
|
+
* diffs a fresh run against it in CI. The JSON shape is the cross-language contract in
|
|
6
|
+
* `@retrace/shared` (CassetteSchema) — keep this writer in sync with it.
|
|
7
|
+
*/
|
|
1
8
|
import type { TraceRecorder } from "./recorder.js";
|
|
2
9
|
export interface CassetteTolerance {
|
|
3
10
|
default?: "exact" | "ignore" | "semantic" | "judge";
|
package/dist/cassette.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Golden cassette writer for CI regression replay (Phase 4b).
|
|
3
|
-
*
|
|
4
|
-
* After recording a run, write its cassette to a file you commit to your repo; `retrace ci replay`
|
|
5
|
-
* diffs a fresh run against it in CI. The JSON shape is the cross-language contract in
|
|
6
|
-
* `@retrace/shared` (CassetteSchema) — keep this writer in sync with it.
|
|
7
|
-
*/
|
|
8
|
-
import { writeFileSync } from "node:fs";
|
|
9
1
|
import { getActiveRecorder } from "./init.js";
|
|
2
|
+
// node:fs is loaded LAZILY (not a static import) so this module — re-exported from the package
|
|
3
|
+
// barrel — still evaluates on edge runtimes. Golden cassettes are a Node/CI feature (writing a file
|
|
4
|
+
// to disk), so on Node we resolve writeFileSync once at module load; on edge writeGoldenCassette
|
|
5
|
+
// throws a clear error. The microtask import resolves long before any real post-run cassette write.
|
|
6
|
+
let _writeFileSync = null;
|
|
7
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
8
|
+
void import("node:fs").then((m) => { _writeFileSync = m.writeFileSync; }).catch(() => { });
|
|
9
|
+
}
|
|
10
10
|
/**
|
|
11
11
|
* Write the active (or given) recorder's run to `path` as a golden cassette. Returns the cassette.
|
|
12
12
|
* Throws if no recorder is active — call it inside/after your traced function, before the process
|
|
@@ -19,6 +19,9 @@ export function writeGoldenCassette(path, opts) {
|
|
|
19
19
|
const cassette = recorder.toCassette();
|
|
20
20
|
if (opts?.tolerance)
|
|
21
21
|
cassette.tolerance = opts.tolerance;
|
|
22
|
-
|
|
22
|
+
if (!_writeFileSync) {
|
|
23
|
+
throw new Error("writeGoldenCassette requires a Node.js filesystem; it isn't available on this runtime (e.g. edge).");
|
|
24
|
+
}
|
|
25
|
+
_writeFileSync(path, JSON.stringify(cassette, null, 2));
|
|
23
26
|
return cassette;
|
|
24
27
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
+
// Runtime-safe env: edge runtimes (Cloudflare Workers, Deno/Supabase Edge) may not define a global
|
|
2
|
+
// `process`, so reading process.env.* directly would throw at module-evaluation time. Fall back to
|
|
3
|
+
// an empty map there; explicit configure({ apiKey, ... }) still works on every runtime.
|
|
4
|
+
const env = typeof process !== "undefined" && process.env ? process.env : {};
|
|
1
5
|
const config = {
|
|
2
|
-
apiKey:
|
|
3
|
-
baseUrl:
|
|
6
|
+
apiKey: env.RETRACE_API_KEY || "",
|
|
7
|
+
baseUrl: env.RETRACE_BASE_URL || "https://api.retraceai.tech",
|
|
4
8
|
wsUrl: "",
|
|
5
|
-
projectId:
|
|
6
|
-
enabled: !["false", "0"].includes((
|
|
7
|
-
sampleRate: parseFloat(
|
|
8
|
-
sampleSeed:
|
|
9
|
-
transport: (["auto", "ws", "http"].includes(
|
|
10
|
-
strictReplay: ["true", "1"].includes((
|
|
11
|
-
maxStepsPerRun:
|
|
12
|
-
maxTokensPerRun:
|
|
13
|
-
maxUsdPerRun:
|
|
14
|
-
serverEnforcement: ["true", "1", "yes"].includes((
|
|
15
|
-
enforcementHoldWaitSeconds:
|
|
16
|
-
environment:
|
|
9
|
+
projectId: env.RETRACE_PROJECT_ID || undefined,
|
|
10
|
+
enabled: !["false", "0"].includes((env.RETRACE_ENABLED || "true").toLowerCase()),
|
|
11
|
+
sampleRate: parseFloat(env.RETRACE_SAMPLE_RATE || "1"),
|
|
12
|
+
sampleSeed: env.RETRACE_SAMPLE_SEED || undefined,
|
|
13
|
+
transport: (["auto", "ws", "http"].includes(env.RETRACE_TRANSPORT || "") ? env.RETRACE_TRANSPORT : "auto"),
|
|
14
|
+
strictReplay: ["true", "1"].includes((env.RETRACE_STRICT_REPLAY || "").toLowerCase()),
|
|
15
|
+
maxStepsPerRun: env.RETRACE_MAX_STEPS_PER_RUN ? parseInt(env.RETRACE_MAX_STEPS_PER_RUN, 10) : undefined,
|
|
16
|
+
maxTokensPerRun: env.RETRACE_MAX_TOKENS_PER_RUN ? parseInt(env.RETRACE_MAX_TOKENS_PER_RUN, 10) : undefined,
|
|
17
|
+
maxUsdPerRun: env.RETRACE_MAX_USD_PER_RUN ? parseFloat(env.RETRACE_MAX_USD_PER_RUN) : undefined,
|
|
18
|
+
serverEnforcement: ["true", "1", "yes"].includes((env.RETRACE_SERVER_ENFORCEMENT || "").toLowerCase()),
|
|
19
|
+
enforcementHoldWaitSeconds: env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS ? parseInt(env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS, 10) : 0,
|
|
20
|
+
environment: env.RETRACE_ENVIRONMENT || undefined,
|
|
17
21
|
};
|
|
18
22
|
config.wsUrl = config.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
19
23
|
export function configure(opts) {
|
package/dist/enforcement.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side enforcement gate (circuit breakers) — the TypeScript twin of the Python
|
|
3
|
+
* `EnforcementGate` (same names, same semantics).
|
|
4
|
+
*
|
|
5
|
+
* LOCAL ceilings (max steps / tokens / USD per run) are enforced entirely offline — zero network, so
|
|
6
|
+
* the breaker trips even when the API is unreachable, and it throws SYNCHRONOUSLY before the next
|
|
7
|
+
* call. When `serverEnforcement` is enabled the gate ALSO consults the server `/enforcement/check`
|
|
8
|
+
* endpoint; because auto-instrumentation routes spans synchronously, that call is best-effort and
|
|
9
|
+
* fire-and-forget — a server block trips the NEXT span (one-span lag) rather than the current one,
|
|
10
|
+
* and a transport failure is logged, never swallowed (server policy is authoritative at ingest).
|
|
11
|
+
*/
|
|
1
12
|
import type { Config } from "./config.js";
|
|
2
13
|
/** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
|
|
3
14
|
export declare function hashToolArgs(args: unknown): string;
|
package/dist/enforcement.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side enforcement gate (circuit breakers) — the TypeScript twin of the Python
|
|
3
|
-
* `EnforcementGate` (same names, same semantics).
|
|
4
|
-
*
|
|
5
|
-
* LOCAL ceilings (max steps / tokens / USD per run) are enforced entirely offline — zero network, so
|
|
6
|
-
* the breaker trips even when the API is unreachable, and it throws SYNCHRONOUSLY before the next
|
|
7
|
-
* call. When `serverEnforcement` is enabled the gate ALSO consults the server `/enforcement/check`
|
|
8
|
-
* endpoint; because auto-instrumentation routes spans synchronously, that call is best-effort and
|
|
9
|
-
* fire-and-forget — a server block trips the NEXT span (one-span lag) rather than the current one,
|
|
10
|
-
* and a transport failure is logged, never swallowed (server policy is authoritative at ingest).
|
|
11
|
-
*/
|
|
12
|
-
import { createHash } from "node:crypto";
|
|
13
1
|
import { RetraceEnforcementError } from "./errors.js";
|
|
2
|
+
// Synchronous, dependency-free hash → 32 hex chars (cyrb53 variant, two seeds for a wider digest).
|
|
3
|
+
// Used ONLY for LOCAL loop/debounce dedup keys within a single run — never a security or
|
|
4
|
+
// cross-language boundary — so a fast non-cryptographic hash is sufficient. This keeps the gate
|
|
5
|
+
// synchronous (Web Crypto's subtle.digest is async) AND runtime-agnostic (no `node:crypto` import,
|
|
6
|
+
// which would break module evaluation on edge runtimes).
|
|
7
|
+
function shortHash(input) {
|
|
8
|
+
const pass = (seed) => {
|
|
9
|
+
let h1 = 0xdeadbeef ^ seed;
|
|
10
|
+
let h2 = 0x41c6ce57 ^ seed;
|
|
11
|
+
for (let i = 0; i < input.length; i++) {
|
|
12
|
+
const ch = input.charCodeAt(i);
|
|
13
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
14
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
15
|
+
}
|
|
16
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
17
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
18
|
+
const n = 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
19
|
+
return n.toString(16).padStart(14, "0");
|
|
20
|
+
};
|
|
21
|
+
return (pass(0) + pass(0x9e3779b9)).slice(0, 32);
|
|
22
|
+
}
|
|
14
23
|
/** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
|
|
15
24
|
export function hashToolArgs(args) {
|
|
16
25
|
let serialized;
|
|
@@ -20,7 +29,7 @@ export function hashToolArgs(args) {
|
|
|
20
29
|
catch {
|
|
21
30
|
serialized = String(args);
|
|
22
31
|
}
|
|
23
|
-
return
|
|
32
|
+
return shortHash(serialized);
|
|
24
33
|
}
|
|
25
34
|
export class EnforcementGate {
|
|
26
35
|
config;
|
|
@@ -3,6 +3,7 @@ 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";
|
|
6
|
+
import { UNKNOWN_MODEL_PRICING, costFromRate } from "../pricing.js";
|
|
6
7
|
const PRICING = {
|
|
7
8
|
"claude-opus-4.7": [5.0, 25.0],
|
|
8
9
|
"claude-opus-4.6": [5.0, 25.0],
|
|
@@ -17,9 +18,10 @@ const PRICING = {
|
|
|
17
18
|
function calcCost(model, inputTokens, outputTokens) {
|
|
18
19
|
for (const [key, p] of Object.entries(PRICING)) {
|
|
19
20
|
if (model.includes(key))
|
|
20
|
-
return (
|
|
21
|
+
return costFromRate(p, inputTokens, outputTokens);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
// Unknown model — never cost $0 (that silently disarms USD budget ceilings). Conservative fallback.
|
|
24
|
+
return costFromRate(UNKNOWN_MODEL_PRICING, inputTokens, outputTokens);
|
|
23
25
|
}
|
|
24
26
|
let originalCreate = null;
|
|
25
27
|
let installed = false;
|
|
@@ -2,6 +2,7 @@ import { SpanType } from "../trace.js";
|
|
|
2
2
|
import { genId, nowIso, truncateJson } from "../utils.js";
|
|
3
3
|
import { dispatchRegisterOpenSpan, dispatchUnregisterOpenSpan, captureActiveSpanEmit } from "./_dispatch.js";
|
|
4
4
|
import { emitGeminiToolCalls, emitGeminiToolResults, resetToolResultDedup, extractToolSchemas, extractSamplingParams } from "./tool-spans.js";
|
|
5
|
+
import { UNKNOWN_MODEL_PRICING, costFromRate } from "../pricing.js";
|
|
5
6
|
const PRICING = {
|
|
6
7
|
"gemini-3.1-flash-lite": [0.10, 0.40],
|
|
7
8
|
"gemini-3.1-flash": [0.50, 3.0],
|
|
@@ -15,8 +16,10 @@ const PRICING = {
|
|
|
15
16
|
"gemini-2.0-flash-lite": [0.05, 0.20],
|
|
16
17
|
};
|
|
17
18
|
function calcCost(model, inputTokens, outputTokens) {
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// Exact-match table (substring matching would mis-rank "…-flash" vs "…-flash-lite"); an unknown
|
|
20
|
+
// model falls back to a conservative non-zero rate so USD budget ceilings still engage.
|
|
21
|
+
const p = PRICING[model] ?? UNKNOWN_MODEL_PRICING;
|
|
22
|
+
return costFromRate(p, inputTokens, outputTokens);
|
|
20
23
|
}
|
|
21
24
|
let onSpanCallback = null;
|
|
22
25
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -5,6 +5,7 @@ import { getConfig } from "../config.js";
|
|
|
5
5
|
import { RetraceRateLimitError, RetraceAuthError, RetraceConnectionError } from "../errors.js";
|
|
6
6
|
import { emitOpenAIToolCalls, emitOpenAIToolResults, parseToolArgs, resetToolResultDedup, extractToolSchemas, extractSamplingParams } from "./tool-spans.js";
|
|
7
7
|
import { dispatchRegisterOpenSpan, dispatchUnregisterOpenSpan } from "./_dispatch.js";
|
|
8
|
+
import { UNKNOWN_MODEL_PRICING, costFromRate } from "../pricing.js";
|
|
8
9
|
/** Hardcoded fallback pricing ($/1M tokens: [input, output]). Updated periodically. */
|
|
9
10
|
const FALLBACK_PRICING = {
|
|
10
11
|
"gpt-5.5-pro": [30.0, 180.0],
|
|
@@ -55,9 +56,10 @@ function calcCost(model, inputTokens, outputTokens) {
|
|
|
55
56
|
const pricing = livePricing || FALLBACK_PRICING;
|
|
56
57
|
for (const [key, p] of Object.entries(pricing)) {
|
|
57
58
|
if (model.includes(key))
|
|
58
|
-
return (
|
|
59
|
+
return costFromRate(p, inputTokens, outputTokens);
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
// Unknown model — never cost $0 (that silently disarms USD budget ceilings). Conservative fallback.
|
|
62
|
+
return costFromRate(UNKNOWN_MODEL_PRICING, inputTokens, outputTokens);
|
|
61
63
|
}
|
|
62
64
|
let originalCreate = null;
|
|
63
65
|
let installed = false;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative fallback pricing for models not present in an interceptor's price table.
|
|
3
|
+
*
|
|
4
|
+
* A model that matches no key (a brand-new release, a fine-tune, an OpenRouter alias) would
|
|
5
|
+
* otherwise be costed at $0 — and a $0 cost silently disarms USD budget ceilings in the enforcement
|
|
6
|
+
* gate (the gate trips on estimated USD per run). We instead cost unknown models at a conservative
|
|
7
|
+
* mid/high frontier rate so a budget cap still engages; the value self-corrects the moment the
|
|
8
|
+
* server price table (synced via /api/v1/pricing/models) learns the model.
|
|
9
|
+
*
|
|
10
|
+
* $/1M tokens: [input, output].
|
|
11
|
+
*/
|
|
12
|
+
export declare const UNKNOWN_MODEL_PRICING: readonly [number, number];
|
|
13
|
+
/** Cost in USD from an [input, output] $/1M-token rate. */
|
|
14
|
+
export declare function costFromRate(rate: readonly [number, number], inputTokens: number, outputTokens: number): number;
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative fallback pricing for models not present in an interceptor's price table.
|
|
3
|
+
*
|
|
4
|
+
* A model that matches no key (a brand-new release, a fine-tune, an OpenRouter alias) would
|
|
5
|
+
* otherwise be costed at $0 — and a $0 cost silently disarms USD budget ceilings in the enforcement
|
|
6
|
+
* gate (the gate trips on estimated USD per run). We instead cost unknown models at a conservative
|
|
7
|
+
* mid/high frontier rate so a budget cap still engages; the value self-corrects the moment the
|
|
8
|
+
* server price table (synced via /api/v1/pricing/models) learns the model.
|
|
9
|
+
*
|
|
10
|
+
* $/1M tokens: [input, output].
|
|
11
|
+
*/
|
|
12
|
+
export const UNKNOWN_MODEL_PRICING = [5.0, 15.0];
|
|
13
|
+
/** Cost in USD from an [input, output] $/1M-token rate. */
|
|
14
|
+
export function costFromRate(rate, inputTokens, outputTokens) {
|
|
15
|
+
return (inputTokens * rate[0] + outputTokens * rate[1]) / 1_000_000;
|
|
16
|
+
}
|
package/dist/telemetry.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getConfig } from "./config.js";
|
|
|
14
14
|
const ANON_ID = Math.random().toString(16).slice(2, 18);
|
|
15
15
|
const DISABLED = new Set(["0", "false", "no", "off"]);
|
|
16
16
|
// Keep in sync with package.json version.
|
|
17
|
-
const SDK_VERSION = "0.
|
|
17
|
+
const SDK_VERSION = "0.16.0";
|
|
18
18
|
function enabled() {
|
|
19
19
|
return !DISABLED.has((process.env.RETRACE_TELEMETRY ?? "1").trim().toLowerCase());
|
|
20
20
|
}
|
package/dist/transport.d.ts
CHANGED
package/dist/transport.js
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
1
|
import { getConfig } from "./config.js";
|
|
3
2
|
import { classifyServerSignal } from "./errors.js";
|
|
4
3
|
// Client identifier sent on every request so the backend can attribute SDK usage/version.
|
|
5
4
|
// Keep in sync with package.json on release.
|
|
6
|
-
const CLIENT_ID = "typescript-sdk/0.
|
|
5
|
+
const CLIENT_ID = "typescript-sdk/0.16.0";
|
|
6
|
+
// ─── Runtime-agnostic WebSocket ──────────────────────────────────────────────
|
|
7
|
+
// Prefer the global Web `WebSocket` (Node 20+, Bun, Deno, browsers, and every edge runtime); fall
|
|
8
|
+
// back to the OPTIONAL `ws` package only on older Node that lacks a global. Both expose the standard
|
|
9
|
+
// `addEventListener`/`send`/`close`/`readyState`/`bufferedAmount` surface used here, so the transport
|
|
10
|
+
// is identical across runtimes. On edge `ws` is simply absent — we use the global, or (if WS can't
|
|
11
|
+
// connect) the HTTP transport via `fetch`.
|
|
12
|
+
const WS_OPEN = 1; // CONNECTING=0, OPEN=1, CLOSING=2, CLOSED=3 (web standard; `ws` matches)
|
|
13
|
+
let _wsCtor = null;
|
|
14
|
+
function resolveWebSocketCtor() {
|
|
15
|
+
if (_wsCtor)
|
|
16
|
+
return _wsCtor;
|
|
17
|
+
const globalWS = globalThis.WebSocket;
|
|
18
|
+
if (typeof globalWS === "function") {
|
|
19
|
+
_wsCtor = Promise.resolve(globalWS);
|
|
20
|
+
return _wsCtor;
|
|
21
|
+
}
|
|
22
|
+
// No global WebSocket (older Node) → try the optional `ws` package. The variable specifier +
|
|
23
|
+
// ignore comments stop edge bundlers from statically resolving a dep that isn't installed there;
|
|
24
|
+
// this branch is never reached on runtimes that ship a global WebSocket.
|
|
25
|
+
const pkg = ["w", "s"].join("");
|
|
26
|
+
_wsCtor = import(/* webpackIgnore: true */ /* @vite-ignore */ pkg)
|
|
27
|
+
.then((m) => {
|
|
28
|
+
const mod = m;
|
|
29
|
+
return (mod.default ?? mod);
|
|
30
|
+
})
|
|
31
|
+
.catch(() => null); // `ws` not installed (e.g. edge build) → caller falls back to HTTP
|
|
32
|
+
return _wsCtor;
|
|
33
|
+
}
|
|
34
|
+
// Normalize a WS message payload to a string across runtimes: the global WebSocket delivers text
|
|
35
|
+
// frames as a string; `ws` delivers a Buffer; ArrayBuffer/typed-array are handled defensively.
|
|
36
|
+
function wsDataToString(data) {
|
|
37
|
+
if (typeof data === "string")
|
|
38
|
+
return data;
|
|
39
|
+
if (data instanceof ArrayBuffer)
|
|
40
|
+
return new TextDecoder().decode(data);
|
|
41
|
+
if (ArrayBuffer.isView(data))
|
|
42
|
+
return new TextDecoder().decode(data);
|
|
43
|
+
return String(data?.toString?.() ?? data);
|
|
44
|
+
}
|
|
7
45
|
export class WSTransport {
|
|
8
46
|
ws = null;
|
|
47
|
+
connecting = false;
|
|
9
48
|
connected = false;
|
|
10
49
|
closed = false;
|
|
11
50
|
backoff = 1000;
|
|
@@ -24,79 +63,94 @@ export class WSTransport {
|
|
|
24
63
|
/** Whether a trace lost events to a buffer drop (so the API/replay can refuse byte-replay). */
|
|
25
64
|
isTraceLossy(traceId) { return this.lossyTraces.has(traceId); }
|
|
26
65
|
connect() {
|
|
27
|
-
if (this.closed)
|
|
66
|
+
if (this.closed || this.ws || this.connecting)
|
|
28
67
|
return;
|
|
68
|
+
this.connecting = true;
|
|
29
69
|
const cfg = getConfig();
|
|
30
70
|
const url = `${cfg.wsUrl}/ws/v1/stream`;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
39
|
-
this.ws.on("message", (raw) => {
|
|
71
|
+
void resolveWebSocketCtor().then((Ctor) => {
|
|
72
|
+
this.connecting = false;
|
|
73
|
+
// No WebSocket implementation (edge without a global, or `ws` not installed), or we were
|
|
74
|
+
// closed/connected while resolving → bail. In "auto" mode the fallback timer switches to HTTP.
|
|
75
|
+
if (!Ctor || this.closed || this.ws)
|
|
76
|
+
return;
|
|
77
|
+
let socket;
|
|
40
78
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
socket = new Ctor(url);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return; // construction unsupported on this runtime → leave disconnected (HTTP fallback)
|
|
83
|
+
}
|
|
84
|
+
this.ws = socket;
|
|
85
|
+
socket.addEventListener("open", () => {
|
|
86
|
+
// Unref the underlying Node socket so a short-lived script can exit once its work is done
|
|
87
|
+
// (no-op on runtimes without `_socket`, e.g. the global WebSocket). Graceful shutdown still
|
|
88
|
+
// drains via flush()/beforeExit.
|
|
89
|
+
socket?._socket?.unref?.();
|
|
90
|
+
socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey }));
|
|
91
|
+
});
|
|
92
|
+
socket.addEventListener("message", (ev) => {
|
|
93
|
+
try {
|
|
94
|
+
const msg = JSON.parse(wsDataToString(ev.data));
|
|
95
|
+
if (msg.type === "auth_ok") {
|
|
96
|
+
this.connected = true;
|
|
97
|
+
this.backoff = 1000;
|
|
98
|
+
this.flushQueue();
|
|
99
|
+
}
|
|
100
|
+
else if (msg.type === "ping") {
|
|
101
|
+
socket.send(JSON.stringify({ type: "pong" }));
|
|
102
|
+
}
|
|
103
|
+
else if (msg.type === "error") {
|
|
104
|
+
this.surfaceSignal(classifyServerSignal("error", msg.error));
|
|
105
|
+
}
|
|
106
|
+
else if (msg.type === "resume") {
|
|
107
|
+
import("./resume.js").then(({ parseResumeMessage, handleResume }) => {
|
|
108
|
+
const cmd = parseResumeMessage(msg);
|
|
109
|
+
if (cmd)
|
|
110
|
+
handleResume(cmd);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else if (msg.type === "replay") {
|
|
114
|
+
import("./replay.js").then(({ parseReplayMessage, handleReplay }) => {
|
|
115
|
+
const cmd = parseReplayMessage(msg);
|
|
116
|
+
if (cmd)
|
|
117
|
+
handleReplay(cmd);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else if (msg.type === "halt") {
|
|
121
|
+
const reason = msg.data?.reason || "Guardrail triggered";
|
|
122
|
+
const signal = classifyServerSignal("halt", reason);
|
|
123
|
+
// halt CHANGES behavior, deliberately: flush what's already recorded, then STOP recording
|
|
124
|
+
// (close the transport). This is a hard server directive, distinct from credits_exhausted
|
|
125
|
+
// (which leaves recording alive so the user can swap keys / upgrade mid-run). Surfaced as a
|
|
126
|
+
// fatal signal — never a silent dark-out.
|
|
127
|
+
this.surfaceSignal(signal);
|
|
128
|
+
void this.flush().finally(() => this.close());
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// DEFAULT: an unhandled/unknown server message type must not be silently swallowed (the
|
|
132
|
+
// F-P6 class). Throttled-warn so a future message type surfaces instead of vanishing.
|
|
133
|
+
this.warnUnknownType(msg.type);
|
|
134
|
+
}
|
|
66
135
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// halt CHANGES behavior, deliberately: flush what's already recorded, then STOP recording
|
|
71
|
-
// (close the transport). This is a hard server directive, distinct from credits_exhausted
|
|
72
|
-
// (which leaves recording alive so the user can swap keys / upgrade mid-run). Surfaced as a
|
|
73
|
-
// fatal signal — never a silent dark-out.
|
|
74
|
-
this.surfaceSignal(signal);
|
|
75
|
-
void this.flush().finally(() => this.close());
|
|
136
|
+
catch (e) {
|
|
137
|
+
// A malformed/unparseable frame must not take down the listener.
|
|
138
|
+
this.throttledSignalWarn("parse", `[retrace] dropped an unparseable server frame: ${e?.message ?? e}`);
|
|
76
139
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
140
|
+
});
|
|
141
|
+
socket.addEventListener("close", () => {
|
|
142
|
+
this.connected = false;
|
|
143
|
+
this.ws = null;
|
|
144
|
+
if (!this.closed) {
|
|
145
|
+
this.reconnectTimer = setTimeout(() => this.reconnect(), this.backoff * (0.5 + Math.random() * 0.5));
|
|
146
|
+
// Don't let the reconnect timer keep the event loop (and the process) alive.
|
|
147
|
+
this.reconnectTimer?.unref?.();
|
|
148
|
+
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
81
149
|
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
this.ws.on("close", () => {
|
|
89
|
-
this.connected = false;
|
|
90
|
-
this.ws = null;
|
|
91
|
-
if (!this.closed) {
|
|
92
|
-
this.reconnectTimer = setTimeout(() => this.reconnect(), this.backoff * (0.5 + Math.random() * 0.5));
|
|
93
|
-
// Don't let the reconnect timer keep the event loop (and the process) alive.
|
|
94
|
-
this.reconnectTimer?.unref?.();
|
|
95
|
-
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
this.ws.on("error", () => {
|
|
99
|
-
this.ws?.close();
|
|
150
|
+
});
|
|
151
|
+
socket.addEventListener("error", () => {
|
|
152
|
+
socket.close();
|
|
153
|
+
});
|
|
100
154
|
});
|
|
101
155
|
}
|
|
102
156
|
reconnect() {
|
|
@@ -173,7 +227,7 @@ export class WSTransport {
|
|
|
173
227
|
}
|
|
174
228
|
/** Whether there is anything worth flushing on exit. */
|
|
175
229
|
hasPendingData() {
|
|
176
|
-
return this.queue.length > 0 || (this.ws?.readyState ===
|
|
230
|
+
return this.queue.length > 0 || (this.ws?.readyState === WS_OPEN && this.ws.bufferedAmount > 0);
|
|
177
231
|
}
|
|
178
232
|
send(eventType, data) {
|
|
179
233
|
// Unconfigured (no API key): never buffer and never reach the network. An imported-but-unused
|
|
@@ -188,7 +242,7 @@ export class WSTransport {
|
|
|
188
242
|
return;
|
|
189
243
|
}
|
|
190
244
|
const item = { eventType, data, traceId, spanId };
|
|
191
|
-
if (this.connected && this.ws?.readyState ===
|
|
245
|
+
if (this.connected && this.ws?.readyState === WS_OPEN) {
|
|
192
246
|
this.transmit(item);
|
|
193
247
|
}
|
|
194
248
|
else {
|
|
@@ -216,7 +270,7 @@ export class WSTransport {
|
|
|
216
270
|
* the process before exit. Best-effort with a hard timeout. */
|
|
217
271
|
async flush() {
|
|
218
272
|
const start = Date.now();
|
|
219
|
-
while (this.ws && this.ws.readyState ===
|
|
273
|
+
while (this.ws && this.ws.readyState === WS_OPEN && this.ws.bufferedAmount > 0 && Date.now() - start < 2000) {
|
|
220
274
|
await new Promise((r) => setTimeout(r, 50));
|
|
221
275
|
}
|
|
222
276
|
}
|
|
@@ -502,6 +556,9 @@ export function onProcessExit(fn) {
|
|
|
502
556
|
* listenerCount gate here; Python via a SIG_DFL/main-thread gate) — that parity is real.
|
|
503
557
|
*/
|
|
504
558
|
export function registerProcessExitFlush(transport, budgetMs = 1500) {
|
|
559
|
+
const proc = globalThis.process;
|
|
560
|
+
if (!proc || typeof proc.on !== "function")
|
|
561
|
+
return;
|
|
505
562
|
const drain = async (reason) => {
|
|
506
563
|
for (const h of preExitHooks) {
|
|
507
564
|
try {
|
|
@@ -520,20 +577,20 @@ export function registerProcessExitFlush(transport, budgetMs = 1500) {
|
|
|
520
577
|
await transport.flush();
|
|
521
578
|
};
|
|
522
579
|
let draining = false;
|
|
523
|
-
|
|
580
|
+
proc.on("beforeExit", () => {
|
|
524
581
|
if (draining)
|
|
525
582
|
return;
|
|
526
583
|
draining = true;
|
|
527
584
|
void drain("graceful").finally(() => { draining = false; });
|
|
528
585
|
});
|
|
529
586
|
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
530
|
-
const userOwnsExit =
|
|
531
|
-
|
|
587
|
+
const userOwnsExit = proc.listenerCount(sig) > 0; // captured BEFORE we register ours
|
|
588
|
+
proc.on(sig, () => {
|
|
532
589
|
drain("signal").finally(() => {
|
|
533
590
|
// Sole listener: we suppressed the default terminate, so we must exit or the process hangs.
|
|
534
591
|
// User has their own handler: they own exit — never call it for them.
|
|
535
592
|
if (!userOwnsExit)
|
|
536
|
-
|
|
593
|
+
proc.exit(sig === "SIGINT" ? 130 : 143);
|
|
537
594
|
});
|
|
538
595
|
});
|
|
539
596
|
}
|
package/dist/utils.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Runtime-agnostic UUID + UTF-8 byte helpers. Web Crypto (globalThis.crypto) and TextEncoder/
|
|
2
|
+
// TextDecoder are available on Node 20+, Bun, Deno, browsers, and every edge runtime — so we avoid
|
|
3
|
+
// a hard `node:crypto` import and the Node-only `Buffer`, both of which break module evaluation on
|
|
4
|
+
// edge runtimes (Cloudflare Workers, Vercel Edge, Supabase Edge).
|
|
2
5
|
export function genId() {
|
|
3
|
-
return randomUUID();
|
|
6
|
+
return globalThis.crypto.randomUUID();
|
|
4
7
|
}
|
|
8
|
+
const _textEncoder = new TextEncoder();
|
|
9
|
+
const _textDecoder = new TextDecoder();
|
|
5
10
|
export function nowIso() {
|
|
6
11
|
return new Date().toISOString();
|
|
7
12
|
}
|
|
@@ -32,9 +37,10 @@ export function shouldSample(rate, seed, key) {
|
|
|
32
37
|
export function truncateJson(obj, maxBytes = 10240) {
|
|
33
38
|
try {
|
|
34
39
|
const s = JSON.stringify(obj);
|
|
35
|
-
|
|
40
|
+
const bytes = _textEncoder.encode(s);
|
|
41
|
+
if (bytes.length <= maxBytes)
|
|
36
42
|
return obj;
|
|
37
|
-
return JSON.parse(
|
|
43
|
+
return JSON.parse(_textDecoder.decode(bytes.subarray(0, maxBytes)));
|
|
38
44
|
}
|
|
39
45
|
catch {
|
|
40
46
|
return String(obj).slice(0, maxBytes);
|
|
@@ -44,7 +50,7 @@ export function truncateJson(obj, maxBytes = 10240) {
|
|
|
44
50
|
* so the server refuses to byte-replay it (the replayed value would differ from the original). */
|
|
45
51
|
export function wasTruncated(obj, maxBytes = 10240) {
|
|
46
52
|
try {
|
|
47
|
-
return
|
|
53
|
+
return _textEncoder.encode(JSON.stringify(obj)).length > maxBytes;
|
|
48
54
|
}
|
|
49
55
|
catch {
|
|
50
56
|
return String(obj).length > maxBytes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retrace-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "vitest run",
|
|
54
54
|
"prepublishOnly": "npm run build"
|
|
55
55
|
},
|
|
56
|
-
"
|
|
56
|
+
"optionalDependencies": {
|
|
57
57
|
"ws": "^8.20.1"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|