retrace-sdk 0.1.4 → 0.1.6
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/sdk.test.d.ts +1 -0
- package/dist/sdk.test.js +132 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +68 -11
- package/dist/transport.test.d.ts +1 -0
- package/dist/transport.test.js +67 -0
- package/package.json +4 -3
- package/src/sdk.test.ts +152 -0
- package/src/transport.test.ts +80 -0
- package/src/transport.ts +65 -10
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/sdk.test.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { configure, getConfig } from "./config.js";
|
|
3
|
+
// Reset config between tests
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
configure({ apiKey: "", baseUrl: "http://localhost:3001", enabled: true, wsUrl: "" });
|
|
6
|
+
});
|
|
7
|
+
describe("Config", () => {
|
|
8
|
+
it("has sensible defaults", () => {
|
|
9
|
+
const c = getConfig();
|
|
10
|
+
expect(c.baseUrl).toBe("http://localhost:3001");
|
|
11
|
+
expect(c.enabled).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("configures custom values", () => {
|
|
14
|
+
configure({ apiKey: "rt_live_test", baseUrl: "https://api.example.com" });
|
|
15
|
+
const c = getConfig();
|
|
16
|
+
expect(c.apiKey).toBe("rt_live_test");
|
|
17
|
+
expect(c.baseUrl).toBe("https://api.example.com");
|
|
18
|
+
expect(c.wsUrl).toBe("wss://api.example.com");
|
|
19
|
+
});
|
|
20
|
+
it("auto-derives wsUrl from baseUrl", () => {
|
|
21
|
+
configure({ baseUrl: "https://custom.io" });
|
|
22
|
+
expect(getConfig().wsUrl).toBe("wss://custom.io");
|
|
23
|
+
configure({ baseUrl: "http://localhost:3001" });
|
|
24
|
+
expect(getConfig().wsUrl).toBe("ws://localhost:3001");
|
|
25
|
+
});
|
|
26
|
+
it("warns on invalid API key prefix", () => {
|
|
27
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
28
|
+
configure({ apiKey: "invalid_key" });
|
|
29
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("rt_live_"));
|
|
30
|
+
spy.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
it("does not warn on valid API key", () => {
|
|
33
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
34
|
+
configure({ apiKey: "rt_live_valid123" });
|
|
35
|
+
expect(spy).not.toHaveBeenCalled();
|
|
36
|
+
spy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("Trace model", () => {
|
|
40
|
+
it("SpanBuilder creates valid span data", async () => {
|
|
41
|
+
const { SpanBuilder, SpanType } = await import("./trace.js");
|
|
42
|
+
const sb = new SpanBuilder("test-span", SpanType.LLM_CALL).start();
|
|
43
|
+
sb.setModel("gpt-4o");
|
|
44
|
+
sb.setInput({ prompt: "hi" });
|
|
45
|
+
const data = sb.end("hello");
|
|
46
|
+
expect(data.name).toBe("test-span");
|
|
47
|
+
expect(data.span_type).toBe("llm_call");
|
|
48
|
+
expect(data.model).toBe("gpt-4o");
|
|
49
|
+
expect(data.output).toBe("hello");
|
|
50
|
+
expect(data.id).toHaveLength(36);
|
|
51
|
+
expect(data.started_at).toBeDefined();
|
|
52
|
+
expect(data.ended_at).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
it("TraceBuilder creates valid trace data", async () => {
|
|
55
|
+
const { TraceBuilder, TraceStatus } = await import("./trace.js");
|
|
56
|
+
const tb = new TraceBuilder();
|
|
57
|
+
tb.start("my-trace", { prompt: "test" });
|
|
58
|
+
const data = tb.end("result", TraceStatus.COMPLETED);
|
|
59
|
+
expect(data.name).toBe("my-trace");
|
|
60
|
+
expect(data.status).toBe("completed");
|
|
61
|
+
expect(data.output).toBe("result");
|
|
62
|
+
expect(data.id).toHaveLength(36);
|
|
63
|
+
});
|
|
64
|
+
it("SpanType enum values", async () => {
|
|
65
|
+
const { SpanType } = await import("./trace.js");
|
|
66
|
+
expect(SpanType.LLM_CALL).toBe("llm_call");
|
|
67
|
+
expect(SpanType.TOOL_CALL).toBe("tool_call");
|
|
68
|
+
expect(SpanType.ERROR).toBe("error");
|
|
69
|
+
expect(SpanType.FORK_POINT).toBe("fork_point");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("Recorder", () => {
|
|
73
|
+
it("trace() wraps sync function", async () => {
|
|
74
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
75
|
+
const { trace } = await import("./recorder.js");
|
|
76
|
+
const fn = trace((x) => `result:${x}`, { name: "sync-fn" });
|
|
77
|
+
expect(fn("hi")).toBe("result:hi");
|
|
78
|
+
});
|
|
79
|
+
it("trace() wraps async function", async () => {
|
|
80
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
81
|
+
const { trace } = await import("./recorder.js");
|
|
82
|
+
const fn = trace(async (x) => `async:${x}`, { name: "async-fn" });
|
|
83
|
+
const result = await fn("hi");
|
|
84
|
+
expect(result).toBe("async:hi");
|
|
85
|
+
});
|
|
86
|
+
it("trace() propagates exceptions", async () => {
|
|
87
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
88
|
+
const { trace } = await import("./recorder.js");
|
|
89
|
+
const fn = trace(() => { throw new Error("boom"); }, { name: "err-fn" });
|
|
90
|
+
expect(() => fn()).toThrow("boom");
|
|
91
|
+
});
|
|
92
|
+
it("disabled SDK is no-op", async () => {
|
|
93
|
+
configure({ apiKey: "rt_live_t", enabled: false });
|
|
94
|
+
const { trace } = await import("./recorder.js");
|
|
95
|
+
const fn = trace((x) => `r:${x}`, { name: "off" });
|
|
96
|
+
expect(fn("hi")).toBe("r:hi");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("Transport", () => {
|
|
100
|
+
it("HTTPTransport accumulates and flushes", async () => {
|
|
101
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1" });
|
|
102
|
+
const { HTTPTransport } = await import("./transport.js");
|
|
103
|
+
const http = new HTTPTransport();
|
|
104
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
105
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
106
|
+
http.send("trace_started", { id: "abc", name: "t", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
|
|
107
|
+
http.send("span_started", { id: "s1", trace_id: "abc", span_type: "llm_call", name: "s", started_at: "2026-01-01T00:00:00Z" });
|
|
108
|
+
http.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "hi" });
|
|
109
|
+
http.send("trace_ended", { id: "abc", ended_at: "2026-01-01T00:00:02Z", status: "completed" });
|
|
110
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
111
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
112
|
+
expect(body.id).toBe("abc");
|
|
113
|
+
expect(body.spans).toHaveLength(1);
|
|
114
|
+
expect(body.spans[0].output).toBe("hi");
|
|
115
|
+
vi.unstubAllGlobals();
|
|
116
|
+
});
|
|
117
|
+
it("createTransport returns correct type", async () => {
|
|
118
|
+
const { createTransport, HTTPTransport } = await import("./transport.js");
|
|
119
|
+
expect(createTransport("http")).toBeInstanceOf(HTTPTransport);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe("README examples", () => {
|
|
123
|
+
it("TypeScript README example works", async () => {
|
|
124
|
+
configure({ apiKey: "rt_live_test", enabled: false });
|
|
125
|
+
const { trace } = await import("./recorder.js");
|
|
126
|
+
const runAgent = trace(async (prompt) => {
|
|
127
|
+
return `Response to: ${prompt}`;
|
|
128
|
+
}, { name: "my-agent" });
|
|
129
|
+
const result = await runAgent("What is quantum computing?");
|
|
130
|
+
expect(result).toContain("quantum");
|
|
131
|
+
});
|
|
132
|
+
});
|
package/dist/transport.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ export interface Transport {
|
|
|
5
5
|
export declare class WSTransport implements Transport {
|
|
6
6
|
private ws;
|
|
7
7
|
private connected;
|
|
8
|
+
private closed;
|
|
8
9
|
private backoff;
|
|
9
10
|
private queue;
|
|
11
|
+
get isConnected(): boolean;
|
|
10
12
|
connect(): void;
|
|
11
13
|
private reconnect;
|
|
12
14
|
private flushQueue;
|
package/dist/transport.js
CHANGED
|
@@ -3,9 +3,13 @@ import { getConfig } from "./config.js";
|
|
|
3
3
|
export class WSTransport {
|
|
4
4
|
ws = null;
|
|
5
5
|
connected = false;
|
|
6
|
+
closed = false;
|
|
6
7
|
backoff = 1000;
|
|
7
8
|
queue = [];
|
|
9
|
+
get isConnected() { return this.connected; }
|
|
8
10
|
connect() {
|
|
11
|
+
if (this.closed)
|
|
12
|
+
return;
|
|
9
13
|
const cfg = getConfig();
|
|
10
14
|
const url = `${cfg.wsUrl}/ws/v1/stream`;
|
|
11
15
|
this.ws = new WebSocket(url);
|
|
@@ -26,15 +30,17 @@ export class WSTransport {
|
|
|
26
30
|
this.ws.on("close", () => {
|
|
27
31
|
this.connected = false;
|
|
28
32
|
this.ws = null;
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
if (!this.closed) {
|
|
34
|
+
setTimeout(() => this.reconnect(), this.backoff);
|
|
35
|
+
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
36
|
+
}
|
|
31
37
|
});
|
|
32
38
|
this.ws.on("error", () => {
|
|
33
39
|
this.ws?.close();
|
|
34
40
|
});
|
|
35
41
|
}
|
|
36
42
|
reconnect() {
|
|
37
|
-
if (!this.connected && !this.ws)
|
|
43
|
+
if (!this.closed && !this.connected && !this.ws)
|
|
38
44
|
this.connect();
|
|
39
45
|
}
|
|
40
46
|
flushQueue() {
|
|
@@ -49,11 +55,12 @@ export class WSTransport {
|
|
|
49
55
|
}
|
|
50
56
|
else {
|
|
51
57
|
this.queue.push(msg);
|
|
52
|
-
if (!this.ws)
|
|
58
|
+
if (!this.ws && !this.closed)
|
|
53
59
|
this.connect();
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
close() {
|
|
63
|
+
this.closed = true;
|
|
57
64
|
if (this.ws) {
|
|
58
65
|
this.ws.close();
|
|
59
66
|
this.ws = null;
|
|
@@ -114,11 +121,61 @@ export function createTransport(mode = "auto") {
|
|
|
114
121
|
return new HTTPTransport();
|
|
115
122
|
if (mode === "ws")
|
|
116
123
|
return new WSTransport();
|
|
117
|
-
// Auto:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
// Auto: start with WS, fall back to HTTP if connection fails within timeout
|
|
125
|
+
const ws = new WSTransport();
|
|
126
|
+
const http = new HTTPTransport();
|
|
127
|
+
let useWs = true;
|
|
128
|
+
let decided = false;
|
|
129
|
+
const buffer = [];
|
|
130
|
+
const fallbackTimer = setTimeout(() => {
|
|
131
|
+
if (!decided && !ws.isConnected) {
|
|
132
|
+
decided = true;
|
|
133
|
+
useWs = false;
|
|
134
|
+
ws.close();
|
|
135
|
+
for (const item of buffer.splice(0))
|
|
136
|
+
http.send(item.eventType, item.data);
|
|
137
|
+
}
|
|
138
|
+
}, 5000);
|
|
139
|
+
ws.connect();
|
|
140
|
+
const originalSend = ws.send.bind(ws);
|
|
141
|
+
const checkConnected = () => {
|
|
142
|
+
if (!decided && ws.isConnected) {
|
|
143
|
+
decided = true;
|
|
144
|
+
clearTimeout(fallbackTimer);
|
|
145
|
+
for (const item of buffer.splice(0))
|
|
146
|
+
originalSend(item.eventType, item.data);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
send(eventType, data) {
|
|
151
|
+
checkConnected();
|
|
152
|
+
if (decided) {
|
|
153
|
+
if (useWs)
|
|
154
|
+
originalSend(eventType, data);
|
|
155
|
+
else
|
|
156
|
+
http.send(eventType, data);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
buffer.push({ eventType, data });
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
clearTimeout(fallbackTimer);
|
|
164
|
+
if (!decided) {
|
|
165
|
+
// WS not yet connected — force HTTP fallback to avoid data loss
|
|
166
|
+
decided = true;
|
|
167
|
+
useWs = false;
|
|
168
|
+
ws.close();
|
|
169
|
+
for (const item of buffer.splice(0))
|
|
170
|
+
http.send(item.eventType, item.data);
|
|
171
|
+
http.close();
|
|
172
|
+
}
|
|
173
|
+
else if (useWs) {
|
|
174
|
+
ws.close();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
http.close();
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
124
181
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { configure } from "./config.js";
|
|
3
|
+
// Mock WebSocket to never connect (simulates unreachable WS endpoint)
|
|
4
|
+
const mockWsInstances = [];
|
|
5
|
+
vi.mock("ws", () => {
|
|
6
|
+
const MockWebSocket = function () {
|
|
7
|
+
this.readyState = 0;
|
|
8
|
+
this._listeners = {};
|
|
9
|
+
this.on = (event, cb) => { (this._listeners[event] ??= []).push(cb); return this; };
|
|
10
|
+
this.send = vi.fn();
|
|
11
|
+
this.close = vi.fn(() => {
|
|
12
|
+
this.readyState = 3;
|
|
13
|
+
for (const cb of (this._listeners["close"] ?? []))
|
|
14
|
+
cb();
|
|
15
|
+
});
|
|
16
|
+
mockWsInstances.push(this);
|
|
17
|
+
};
|
|
18
|
+
MockWebSocket.OPEN = 1;
|
|
19
|
+
return { default: MockWebSocket };
|
|
20
|
+
});
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
configure({ apiKey: "rt_live_test", baseUrl: "http://localhost:3001", enabled: true });
|
|
23
|
+
mockWsInstances.length = 0;
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
describe("Auto transport fallback on early close()", () => {
|
|
29
|
+
it("flushes to HTTP when close() is called before WS connects", async () => {
|
|
30
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
31
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
32
|
+
const { createTransport } = await import("./transport.js");
|
|
33
|
+
const transport = createTransport("auto");
|
|
34
|
+
// Send trace events immediately (before WS could connect)
|
|
35
|
+
transport.send("trace_started", { id: "t1", name: "fast-trace", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
|
|
36
|
+
transport.send("span_started", { id: "s1", trace_id: "t1", span_type: "llm_call", name: "call", started_at: "2026-01-01T00:00:00Z" });
|
|
37
|
+
transport.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "response" });
|
|
38
|
+
transport.send("trace_ended", { id: "t1", ended_at: "2026-01-01T00:00:01Z", status: "completed" });
|
|
39
|
+
// Close immediately — WS has NOT connected yet
|
|
40
|
+
transport.close();
|
|
41
|
+
// Should have fallen back to HTTP and flushed
|
|
42
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
43
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
44
|
+
expect(url).toBe("http://localhost:3001/api/v1/traces");
|
|
45
|
+
const body = JSON.parse(opts.body);
|
|
46
|
+
expect(body.id).toBe("t1");
|
|
47
|
+
expect(body.name).toBe("fast-trace");
|
|
48
|
+
expect(body.spans).toHaveLength(1);
|
|
49
|
+
expect(body.spans[0].output).toBe("response");
|
|
50
|
+
});
|
|
51
|
+
it("does NOT reconnect after intentional close()", async () => {
|
|
52
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
|
53
|
+
const { WSTransport } = await import("./transport.js");
|
|
54
|
+
const ws = new WSTransport();
|
|
55
|
+
ws.connect();
|
|
56
|
+
// Should have created a WS instance
|
|
57
|
+
expect(mockWsInstances.length).toBeGreaterThan(0);
|
|
58
|
+
const instanceCount = mockWsInstances.length;
|
|
59
|
+
// Close intentionally
|
|
60
|
+
ws.close();
|
|
61
|
+
// Trigger the "close" event that was registered — already fired by our mock's close()
|
|
62
|
+
// Wait a tick for any setTimeout reconnect to fire
|
|
63
|
+
await new Promise(r => setTimeout(r, 1100));
|
|
64
|
+
// Should NOT have created new WS instances (no reconnect)
|
|
65
|
+
expect(mockWsInstances.length).toBe(instanceCount);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retrace-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"
|
|
32
|
+
"@google/genai": "^1.52.0",
|
|
33
33
|
"@types/node": "22.15.3",
|
|
34
|
-
"@types/ws": "8.18.1"
|
|
34
|
+
"@types/ws": "8.18.1",
|
|
35
|
+
"typescript": "6.0.3"
|
|
35
36
|
}
|
|
36
37
|
}
|
package/src/sdk.test.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { configure, getConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
// Reset config between tests
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
configure({ apiKey: "", baseUrl: "http://localhost:3001", enabled: true, wsUrl: "" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("Config", () => {
|
|
10
|
+
it("has sensible defaults", () => {
|
|
11
|
+
const c = getConfig();
|
|
12
|
+
expect(c.baseUrl).toBe("http://localhost:3001");
|
|
13
|
+
expect(c.enabled).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("configures custom values", () => {
|
|
17
|
+
configure({ apiKey: "rt_live_test", baseUrl: "https://api.example.com" });
|
|
18
|
+
const c = getConfig();
|
|
19
|
+
expect(c.apiKey).toBe("rt_live_test");
|
|
20
|
+
expect(c.baseUrl).toBe("https://api.example.com");
|
|
21
|
+
expect(c.wsUrl).toBe("wss://api.example.com");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("auto-derives wsUrl from baseUrl", () => {
|
|
25
|
+
configure({ baseUrl: "https://custom.io" });
|
|
26
|
+
expect(getConfig().wsUrl).toBe("wss://custom.io");
|
|
27
|
+
configure({ baseUrl: "http://localhost:3001" });
|
|
28
|
+
expect(getConfig().wsUrl).toBe("ws://localhost:3001");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("warns on invalid API key prefix", () => {
|
|
32
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
33
|
+
configure({ apiKey: "invalid_key" });
|
|
34
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("rt_live_"));
|
|
35
|
+
spy.mockRestore();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not warn on valid API key", () => {
|
|
39
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
40
|
+
configure({ apiKey: "rt_live_valid123" });
|
|
41
|
+
expect(spy).not.toHaveBeenCalled();
|
|
42
|
+
spy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Trace model", () => {
|
|
47
|
+
it("SpanBuilder creates valid span data", async () => {
|
|
48
|
+
const { SpanBuilder, SpanType } = await import("./trace.js");
|
|
49
|
+
const sb = new SpanBuilder("test-span", SpanType.LLM_CALL).start();
|
|
50
|
+
sb.setModel("gpt-4o");
|
|
51
|
+
sb.setInput({ prompt: "hi" });
|
|
52
|
+
const data = sb.end("hello");
|
|
53
|
+
expect(data.name).toBe("test-span");
|
|
54
|
+
expect(data.span_type).toBe("llm_call");
|
|
55
|
+
expect(data.model).toBe("gpt-4o");
|
|
56
|
+
expect(data.output).toBe("hello");
|
|
57
|
+
expect(data.id).toHaveLength(36);
|
|
58
|
+
expect(data.started_at).toBeDefined();
|
|
59
|
+
expect(data.ended_at).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("TraceBuilder creates valid trace data", async () => {
|
|
63
|
+
const { TraceBuilder, TraceStatus } = await import("./trace.js");
|
|
64
|
+
const tb = new TraceBuilder();
|
|
65
|
+
tb.start("my-trace", { prompt: "test" });
|
|
66
|
+
const data = tb.end("result", TraceStatus.COMPLETED);
|
|
67
|
+
expect(data.name).toBe("my-trace");
|
|
68
|
+
expect(data.status).toBe("completed");
|
|
69
|
+
expect(data.output).toBe("result");
|
|
70
|
+
expect(data.id).toHaveLength(36);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("SpanType enum values", async () => {
|
|
74
|
+
const { SpanType } = await import("./trace.js");
|
|
75
|
+
expect(SpanType.LLM_CALL).toBe("llm_call");
|
|
76
|
+
expect(SpanType.TOOL_CALL).toBe("tool_call");
|
|
77
|
+
expect(SpanType.ERROR).toBe("error");
|
|
78
|
+
expect(SpanType.FORK_POINT).toBe("fork_point");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("Recorder", () => {
|
|
83
|
+
it("trace() wraps sync function", async () => {
|
|
84
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
85
|
+
const { trace } = await import("./recorder.js");
|
|
86
|
+
const fn = trace((x: unknown) => `result:${x}`, { name: "sync-fn" });
|
|
87
|
+
expect(fn("hi")).toBe("result:hi");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("trace() wraps async function", async () => {
|
|
91
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
92
|
+
const { trace } = await import("./recorder.js");
|
|
93
|
+
const fn = trace(async (x: unknown) => `async:${x}`, { name: "async-fn" });
|
|
94
|
+
const result = await fn("hi");
|
|
95
|
+
expect(result).toBe("async:hi");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("trace() propagates exceptions", async () => {
|
|
99
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1", enabled: true });
|
|
100
|
+
const { trace } = await import("./recorder.js");
|
|
101
|
+
const fn = trace(() => { throw new Error("boom"); }, { name: "err-fn" });
|
|
102
|
+
expect(() => fn()).toThrow("boom");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("disabled SDK is no-op", async () => {
|
|
106
|
+
configure({ apiKey: "rt_live_t", enabled: false });
|
|
107
|
+
const { trace } = await import("./recorder.js");
|
|
108
|
+
const fn = trace((x: unknown) => `r:${x}`, { name: "off" });
|
|
109
|
+
expect(fn("hi")).toBe("r:hi");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("Transport", () => {
|
|
114
|
+
it("HTTPTransport accumulates and flushes", async () => {
|
|
115
|
+
configure({ apiKey: "rt_live_t", baseUrl: "http://x:1" });
|
|
116
|
+
const { HTTPTransport } = await import("./transport.js");
|
|
117
|
+
const http = new HTTPTransport();
|
|
118
|
+
|
|
119
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
120
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
121
|
+
|
|
122
|
+
http.send("trace_started", { id: "abc", name: "t", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
|
|
123
|
+
http.send("span_started", { id: "s1", trace_id: "abc", span_type: "llm_call", name: "s", started_at: "2026-01-01T00:00:00Z" });
|
|
124
|
+
http.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "hi" });
|
|
125
|
+
http.send("trace_ended", { id: "abc", ended_at: "2026-01-01T00:00:02Z", status: "completed" });
|
|
126
|
+
|
|
127
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
128
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
129
|
+
expect(body.id).toBe("abc");
|
|
130
|
+
expect(body.spans).toHaveLength(1);
|
|
131
|
+
expect(body.spans[0].output).toBe("hi");
|
|
132
|
+
|
|
133
|
+
vi.unstubAllGlobals();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("createTransport returns correct type", async () => {
|
|
137
|
+
const { createTransport, HTTPTransport } = await import("./transport.js");
|
|
138
|
+
expect(createTransport("http")).toBeInstanceOf(HTTPTransport);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("README examples", () => {
|
|
143
|
+
it("TypeScript README example works", async () => {
|
|
144
|
+
configure({ apiKey: "rt_live_test", enabled: false });
|
|
145
|
+
const { trace } = await import("./recorder.js");
|
|
146
|
+
const runAgent = trace(async (prompt: unknown) => {
|
|
147
|
+
return `Response to: ${prompt}`;
|
|
148
|
+
}, { name: "my-agent" });
|
|
149
|
+
const result = await runAgent("What is quantum computing?");
|
|
150
|
+
expect(result).toContain("quantum");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { configure } from "./config.js";
|
|
3
|
+
|
|
4
|
+
// Mock WebSocket to never connect (simulates unreachable WS endpoint)
|
|
5
|
+
const mockWsInstances: Array<Record<string, unknown>> = [];
|
|
6
|
+
vi.mock("ws", () => {
|
|
7
|
+
const MockWebSocket = function (this: Record<string, unknown>) {
|
|
8
|
+
this.readyState = 0;
|
|
9
|
+
this._listeners = {} as Record<string, Array<() => void>>;
|
|
10
|
+
this.on = (event: string, cb: () => void) => { ((this._listeners as Record<string, Array<() => void>>)[event] ??= []).push(cb); return this; };
|
|
11
|
+
this.send = vi.fn();
|
|
12
|
+
this.close = vi.fn(() => {
|
|
13
|
+
this.readyState = 3;
|
|
14
|
+
for (const cb of ((this._listeners as Record<string, Array<() => void>>)["close"] ?? [])) cb();
|
|
15
|
+
});
|
|
16
|
+
mockWsInstances.push(this);
|
|
17
|
+
} as unknown as { new(): Record<string, unknown>; OPEN: number };
|
|
18
|
+
MockWebSocket.OPEN = 1;
|
|
19
|
+
return { default: MockWebSocket };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
configure({ apiKey: "rt_live_test", baseUrl: "http://localhost:3001", enabled: true });
|
|
24
|
+
mockWsInstances.length = 0;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("Auto transport fallback on early close()", () => {
|
|
32
|
+
it("flushes to HTTP when close() is called before WS connects", async () => {
|
|
33
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
34
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
35
|
+
|
|
36
|
+
const { createTransport } = await import("./transport.js");
|
|
37
|
+
const transport = createTransport("auto");
|
|
38
|
+
|
|
39
|
+
// Send trace events immediately (before WS could connect)
|
|
40
|
+
transport.send("trace_started", { id: "t1", name: "fast-trace", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
|
|
41
|
+
transport.send("span_started", { id: "s1", trace_id: "t1", span_type: "llm_call", name: "call", started_at: "2026-01-01T00:00:00Z" });
|
|
42
|
+
transport.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "response" });
|
|
43
|
+
transport.send("trace_ended", { id: "t1", ended_at: "2026-01-01T00:00:01Z", status: "completed" });
|
|
44
|
+
|
|
45
|
+
// Close immediately — WS has NOT connected yet
|
|
46
|
+
transport.close();
|
|
47
|
+
|
|
48
|
+
// Should have fallen back to HTTP and flushed
|
|
49
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
50
|
+
const [url, opts] = mockFetch.mock.calls[0];
|
|
51
|
+
expect(url).toBe("http://localhost:3001/api/v1/traces");
|
|
52
|
+
const body = JSON.parse(opts.body);
|
|
53
|
+
expect(body.id).toBe("t1");
|
|
54
|
+
expect(body.name).toBe("fast-trace");
|
|
55
|
+
expect(body.spans).toHaveLength(1);
|
|
56
|
+
expect(body.spans[0].output).toBe("response");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does NOT reconnect after intentional close()", async () => {
|
|
60
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
|
61
|
+
|
|
62
|
+
const { WSTransport } = await import("./transport.js");
|
|
63
|
+
const ws = new WSTransport();
|
|
64
|
+
ws.connect();
|
|
65
|
+
|
|
66
|
+
// Should have created a WS instance
|
|
67
|
+
expect(mockWsInstances.length).toBeGreaterThan(0);
|
|
68
|
+
const instanceCount = mockWsInstances.length;
|
|
69
|
+
|
|
70
|
+
// Close intentionally
|
|
71
|
+
ws.close();
|
|
72
|
+
|
|
73
|
+
// Trigger the "close" event that was registered — already fired by our mock's close()
|
|
74
|
+
// Wait a tick for any setTimeout reconnect to fire
|
|
75
|
+
await new Promise(r => setTimeout(r, 1100));
|
|
76
|
+
|
|
77
|
+
// Should NOT have created new WS instances (no reconnect)
|
|
78
|
+
expect(mockWsInstances.length).toBe(instanceCount);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/transport.ts
CHANGED
|
@@ -9,10 +9,14 @@ export interface Transport {
|
|
|
9
9
|
export class WSTransport implements Transport {
|
|
10
10
|
private ws: WebSocket | null = null;
|
|
11
11
|
private connected = false;
|
|
12
|
+
private closed = false;
|
|
12
13
|
private backoff = 1000;
|
|
13
14
|
private queue: string[] = [];
|
|
14
15
|
|
|
16
|
+
get isConnected() { return this.connected; }
|
|
17
|
+
|
|
15
18
|
connect() {
|
|
19
|
+
if (this.closed) return;
|
|
16
20
|
const cfg = getConfig();
|
|
17
21
|
const url = `${cfg.wsUrl}/ws/v1/stream`;
|
|
18
22
|
this.ws = new WebSocket(url);
|
|
@@ -35,8 +39,10 @@ export class WSTransport implements Transport {
|
|
|
35
39
|
this.ws.on("close", () => {
|
|
36
40
|
this.connected = false;
|
|
37
41
|
this.ws = null;
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
if (!this.closed) {
|
|
43
|
+
setTimeout(() => this.reconnect(), this.backoff);
|
|
44
|
+
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
45
|
+
}
|
|
40
46
|
});
|
|
41
47
|
|
|
42
48
|
this.ws.on("error", () => {
|
|
@@ -45,7 +51,7 @@ export class WSTransport implements Transport {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
private reconnect() {
|
|
48
|
-
if (!this.connected && !this.ws) this.connect();
|
|
54
|
+
if (!this.closed && !this.connected && !this.ws) this.connect();
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
private flushQueue() {
|
|
@@ -60,11 +66,12 @@ export class WSTransport implements Transport {
|
|
|
60
66
|
this.ws.send(msg);
|
|
61
67
|
} else {
|
|
62
68
|
this.queue.push(msg);
|
|
63
|
-
if (!this.ws) this.connect();
|
|
69
|
+
if (!this.ws && !this.closed) this.connect();
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
close() {
|
|
74
|
+
this.closed = true;
|
|
68
75
|
if (this.ws) {
|
|
69
76
|
this.ws.close();
|
|
70
77
|
this.ws = null;
|
|
@@ -124,10 +131,58 @@ export class HTTPTransport implements Transport {
|
|
|
124
131
|
export function createTransport(mode: "ws" | "http" | "auto" = "auto"): Transport {
|
|
125
132
|
if (mode === "http") return new HTTPTransport();
|
|
126
133
|
if (mode === "ws") return new WSTransport();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
|
|
135
|
+
// Auto: start with WS, fall back to HTTP if connection fails within timeout
|
|
136
|
+
const ws = new WSTransport();
|
|
137
|
+
const http = new HTTPTransport();
|
|
138
|
+
let useWs = true;
|
|
139
|
+
let decided = false;
|
|
140
|
+
const buffer: Array<{ eventType: string; data: Record<string, unknown> }> = [];
|
|
141
|
+
|
|
142
|
+
const fallbackTimer = setTimeout(() => {
|
|
143
|
+
if (!decided && !ws.isConnected) {
|
|
144
|
+
decided = true;
|
|
145
|
+
useWs = false;
|
|
146
|
+
ws.close();
|
|
147
|
+
for (const item of buffer.splice(0)) http.send(item.eventType, item.data);
|
|
148
|
+
}
|
|
149
|
+
}, 5000);
|
|
150
|
+
|
|
151
|
+
ws.connect();
|
|
152
|
+
|
|
153
|
+
const originalSend = ws.send.bind(ws);
|
|
154
|
+
const checkConnected = () => {
|
|
155
|
+
if (!decided && ws.isConnected) {
|
|
156
|
+
decided = true;
|
|
157
|
+
clearTimeout(fallbackTimer);
|
|
158
|
+
for (const item of buffer.splice(0)) originalSend(item.eventType, item.data);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
send(eventType: string, data: Record<string, unknown>) {
|
|
164
|
+
checkConnected();
|
|
165
|
+
if (decided) {
|
|
166
|
+
if (useWs) originalSend(eventType, data);
|
|
167
|
+
else http.send(eventType, data);
|
|
168
|
+
} else {
|
|
169
|
+
buffer.push({ eventType, data });
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
close() {
|
|
173
|
+
clearTimeout(fallbackTimer);
|
|
174
|
+
if (!decided) {
|
|
175
|
+
// WS not yet connected — force HTTP fallback to avoid data loss
|
|
176
|
+
decided = true;
|
|
177
|
+
useWs = false;
|
|
178
|
+
ws.close();
|
|
179
|
+
for (const item of buffer.splice(0)) http.send(item.eventType, item.data);
|
|
180
|
+
http.close();
|
|
181
|
+
} else if (useWs) {
|
|
182
|
+
ws.close();
|
|
183
|
+
} else {
|
|
184
|
+
http.close();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
133
188
|
}
|