retrace-sdk 0.1.3
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 +94 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +21 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +15 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/interceptors/gemini.d.ts +3 -0
- package/dist/interceptors/gemini.js +82 -0
- package/dist/interceptors/index.d.ts +1 -0
- package/dist/interceptors/index.js +1 -0
- package/dist/recorder.d.ts +22 -0
- package/dist/recorder.js +123 -0
- package/dist/trace.d.ts +74 -0
- package/dist/trace.js +93 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +124 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +21 -0
- package/package.json +36 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +31 -0
- package/src/index.ts +6 -0
- package/src/interceptors/gemini.ts +86 -0
- package/src/interceptors/index.ts +1 -0
- package/src/recorder.ts +136 -0
- package/src/trace.ts +135 -0
- package/src/transport.ts +133 -0
- package/src/utils.ts +23 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +2 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
export class WSTransport {
|
|
4
|
+
ws = null;
|
|
5
|
+
connected = false;
|
|
6
|
+
backoff = 1000;
|
|
7
|
+
queue = [];
|
|
8
|
+
connect() {
|
|
9
|
+
const cfg = getConfig();
|
|
10
|
+
const url = `${cfg.wsUrl}/ws/v1/stream`;
|
|
11
|
+
this.ws = new WebSocket(url);
|
|
12
|
+
this.ws.on("open", () => {
|
|
13
|
+
this.ws.send(JSON.stringify({ type: "auth", api_key: cfg.apiKey }));
|
|
14
|
+
});
|
|
15
|
+
this.ws.on("message", (raw) => {
|
|
16
|
+
const msg = JSON.parse(raw.toString());
|
|
17
|
+
if (msg.type === "auth_ok") {
|
|
18
|
+
this.connected = true;
|
|
19
|
+
this.backoff = 1000;
|
|
20
|
+
this.flushQueue();
|
|
21
|
+
}
|
|
22
|
+
else if (msg.type === "ping") {
|
|
23
|
+
this.ws?.send(JSON.stringify({ type: "pong" }));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
this.ws.on("close", () => {
|
|
27
|
+
this.connected = false;
|
|
28
|
+
this.ws = null;
|
|
29
|
+
setTimeout(() => this.reconnect(), this.backoff);
|
|
30
|
+
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
31
|
+
});
|
|
32
|
+
this.ws.on("error", () => {
|
|
33
|
+
this.ws?.close();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
reconnect() {
|
|
37
|
+
if (!this.connected && !this.ws)
|
|
38
|
+
this.connect();
|
|
39
|
+
}
|
|
40
|
+
flushQueue() {
|
|
41
|
+
while (this.queue.length && this.connected) {
|
|
42
|
+
this.ws?.send(this.queue.shift());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
send(eventType, data) {
|
|
46
|
+
const msg = JSON.stringify({ type: eventType, data });
|
|
47
|
+
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
48
|
+
this.ws.send(msg);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
this.queue.push(msg);
|
|
52
|
+
if (!this.ws)
|
|
53
|
+
this.connect();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
close() {
|
|
57
|
+
if (this.ws) {
|
|
58
|
+
this.ws.close();
|
|
59
|
+
this.ws = null;
|
|
60
|
+
}
|
|
61
|
+
this.connected = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export class HTTPTransport {
|
|
65
|
+
traceData = null;
|
|
66
|
+
spans = [];
|
|
67
|
+
send(eventType, data) {
|
|
68
|
+
if (eventType === "trace_started") {
|
|
69
|
+
this.traceData = data;
|
|
70
|
+
}
|
|
71
|
+
else if (eventType === "span_started" || eventType === "span_ended") {
|
|
72
|
+
this.spans.push({ ...data, _event: eventType });
|
|
73
|
+
}
|
|
74
|
+
else if (eventType === "trace_ended") {
|
|
75
|
+
if (this.traceData)
|
|
76
|
+
Object.assign(this.traceData, data);
|
|
77
|
+
this.flush();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
flush() {
|
|
81
|
+
if (!this.traceData)
|
|
82
|
+
return;
|
|
83
|
+
const cfg = getConfig();
|
|
84
|
+
const url = `${cfg.baseUrl}/api/v1/traces`;
|
|
85
|
+
const body = { ...this.traceData, spans: this.buildSpans() };
|
|
86
|
+
fetch(url, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
}).catch(() => { });
|
|
91
|
+
this.traceData = null;
|
|
92
|
+
this.spans = [];
|
|
93
|
+
}
|
|
94
|
+
buildSpans() {
|
|
95
|
+
const merged = new Map();
|
|
96
|
+
for (const ev of this.spans) {
|
|
97
|
+
const { _event, ...rest } = ev;
|
|
98
|
+
const id = rest.id;
|
|
99
|
+
if (_event === "span_started") {
|
|
100
|
+
merged.set(id, rest);
|
|
101
|
+
}
|
|
102
|
+
else if (_event === "span_ended" && merged.has(id)) {
|
|
103
|
+
Object.assign(merged.get(id), rest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...merged.values()];
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
this.flush();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function createTransport(mode = "auto") {
|
|
113
|
+
if (mode === "http")
|
|
114
|
+
return new HTTPTransport();
|
|
115
|
+
if (mode === "ws")
|
|
116
|
+
return new WSTransport();
|
|
117
|
+
// Auto: try WS
|
|
118
|
+
try {
|
|
119
|
+
return new WSTransport();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return new HTTPTransport();
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/utils.d.ts
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
export function genId() {
|
|
3
|
+
return randomUUID();
|
|
4
|
+
}
|
|
5
|
+
export function nowIso() {
|
|
6
|
+
return new Date().toISOString();
|
|
7
|
+
}
|
|
8
|
+
export function utcNow() {
|
|
9
|
+
return new Date();
|
|
10
|
+
}
|
|
11
|
+
export function truncateJson(obj, maxBytes = 10240) {
|
|
12
|
+
try {
|
|
13
|
+
const s = JSON.stringify(obj);
|
|
14
|
+
if (Buffer.byteLength(s) <= maxBytes)
|
|
15
|
+
return obj;
|
|
16
|
+
return JSON.parse(Buffer.from(s).subarray(0, maxBytes).toString());
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return String(obj).slice(0, maxBytes);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "retrace-sdk",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "The execution replay engine for AI agents. Record, replay, fork, and share agent executions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"lint": "eslint src/",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"ws": "8.20.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@google/genai": ">=1.52.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"@google/genai": {
|
|
28
|
+
"optional": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"typescript": "6.0.3",
|
|
33
|
+
"@types/node": "22.15.3",
|
|
34
|
+
"@types/ws": "8.18.1"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { configure, getConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
describe("SDK config", () => {
|
|
5
|
+
it("has defaults", () => {
|
|
6
|
+
const c = getConfig();
|
|
7
|
+
expect(c.baseUrl).toBe("http://localhost:3001");
|
|
8
|
+
expect(c.enabled).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
it("configures custom values", () => {
|
|
11
|
+
configure({ apiKey: "rt_live_test", baseUrl: "http://custom:3001" });
|
|
12
|
+
const c = getConfig();
|
|
13
|
+
expect(c.apiKey).toBe("rt_live_test");
|
|
14
|
+
expect(c.baseUrl).toBe("http://custom:3001");
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
wsUrl: string;
|
|
5
|
+
projectId: string | undefined;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const config: Config = {
|
|
10
|
+
apiKey: process.env.RETRACE_API_KEY || "",
|
|
11
|
+
baseUrl: process.env.RETRACE_BASE_URL || "http://localhost:3001",
|
|
12
|
+
wsUrl: "",
|
|
13
|
+
projectId: process.env.RETRACE_PROJECT_ID || undefined,
|
|
14
|
+
enabled: !["false", "0"].includes((process.env.RETRACE_ENABLED || "true").toLowerCase()),
|
|
15
|
+
};
|
|
16
|
+
config.wsUrl = config.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
17
|
+
|
|
18
|
+
export function configure(opts: Partial<Config>): Config {
|
|
19
|
+
if (opts.apiKey && !opts.apiKey.startsWith("rt_live_")) {
|
|
20
|
+
console.warn("[retrace] API key does not start with 'rt_live_'. This may be invalid.");
|
|
21
|
+
}
|
|
22
|
+
Object.assign(config, opts);
|
|
23
|
+
if (opts.baseUrl && !opts.wsUrl) {
|
|
24
|
+
config.wsUrl = config.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
|
|
25
|
+
}
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getConfig(): Config {
|
|
30
|
+
return config;
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { configure, getConfig } from "./config.js";
|
|
2
|
+
export { record, trace, TraceRecorder } from "./recorder.js";
|
|
3
|
+
export { SpanBuilder, TraceBuilder } from "./trace.js";
|
|
4
|
+
export type { SpanData, TraceData } from "./trace.js";
|
|
5
|
+
export { SpanType, TraceStatus } from "./trace.js";
|
|
6
|
+
export { installGeminiInterceptor, uninstallGeminiInterceptor } from "./interceptors/gemini.js";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { SpanData, SpanType } from "../trace.js";
|
|
2
|
+
import { genId, nowIso, truncateJson } from "../utils.js";
|
|
3
|
+
|
|
4
|
+
const PRICING: Record<string, [number, number]> = {
|
|
5
|
+
"gemini-3.1-pro-preview": [2.0, 12.0],
|
|
6
|
+
"gemini-2.5-pro": [1.25, 10.0],
|
|
7
|
+
"gemini-2.5-flash": [0.15, 0.60],
|
|
8
|
+
"gemini-2.0-flash": [0.10, 0.40],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function calcCost(model: string, inputTokens: number, outputTokens: number): number {
|
|
12
|
+
const p = PRICING[model] || [0, 0];
|
|
13
|
+
return (inputTokens * p[0] + outputTokens * p[1]) / 1_000_000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
let originalGenerateContent: ((...args: unknown[]) => unknown) | null = null;
|
|
18
|
+
let installed = false;
|
|
19
|
+
let onSpanCallback: ((span: SpanData) => void) | null = null;
|
|
20
|
+
|
|
21
|
+
export function installGeminiInterceptor(onSpan: (span: SpanData) => void) {
|
|
22
|
+
if (installed) { onSpanCallback = onSpan; return; }
|
|
23
|
+
onSpanCallback = onSpan;
|
|
24
|
+
|
|
25
|
+
import("@google/genai").then((genaiMod) => {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const mod = genaiMod as Record<string, any>;
|
|
28
|
+
const modelsProto = mod?.Models?.prototype || mod?.default?.Models?.prototype;
|
|
29
|
+
if (!modelsProto?.generateContent) return;
|
|
30
|
+
|
|
31
|
+
originalGenerateContent = modelsProto.generateContent;
|
|
32
|
+
|
|
33
|
+
modelsProto.generateContent = async function (...args: unknown[]) {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
const opts = (args[0] as Record<string, any>) || {};
|
|
36
|
+
const model = (opts.model as string) || "unknown";
|
|
37
|
+
const contents = opts.contents;
|
|
38
|
+
const spanId = genId();
|
|
39
|
+
const startedAt = nowIso();
|
|
40
|
+
const startMs = Date.now();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await originalGenerateContent!.apply(this, args);
|
|
44
|
+
const durationMs = Date.now() - startMs;
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
const res = result as any;
|
|
47
|
+
const inputTokens = res?.usageMetadata?.promptTokenCount || 0;
|
|
48
|
+
const outputTokens = res?.usageMetadata?.candidatesTokenCount || 0;
|
|
49
|
+
|
|
50
|
+
const span: SpanData = {
|
|
51
|
+
id: spanId, trace_id: "", parent_id: null,
|
|
52
|
+
span_type: SpanType.LLM_CALL, name: "retrace.ai.generate", model,
|
|
53
|
+
input: truncateJson(contents), output: truncateJson(res?.text || ""),
|
|
54
|
+
input_tokens: inputTokens, output_tokens: outputTokens,
|
|
55
|
+
cost: calcCost(model, inputTokens, outputTokens),
|
|
56
|
+
duration_ms: durationMs, started_at: startedAt, ended_at: nowIso(),
|
|
57
|
+
};
|
|
58
|
+
onSpanCallback?.(span);
|
|
59
|
+
return result;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const span: SpanData = {
|
|
62
|
+
id: spanId, trace_id: "", parent_id: null,
|
|
63
|
+
span_type: SpanType.LLM_CALL, name: "retrace.ai.generate", model,
|
|
64
|
+
input: truncateJson(contents), started_at: startedAt, ended_at: nowIso(),
|
|
65
|
+
duration_ms: Date.now() - startMs,
|
|
66
|
+
error: err instanceof Error ? err.message : String(err),
|
|
67
|
+
};
|
|
68
|
+
onSpanCallback?.(span);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
installed = true;
|
|
73
|
+
}).catch(() => { /* @google/genai not installed — skip */ });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function uninstallGeminiInterceptor() {
|
|
77
|
+
if (!installed || !originalGenerateContent) return;
|
|
78
|
+
import("@google/genai").then((genaiMod) => {
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
const mod = genaiMod as Record<string, any>;
|
|
81
|
+
const modelsProto = mod?.Models?.prototype || mod?.default?.Models?.prototype;
|
|
82
|
+
if (modelsProto) modelsProto.generateContent = originalGenerateContent;
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
installed = false;
|
|
85
|
+
onSpanCallback = null;
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { installGeminiInterceptor, uninstallGeminiInterceptor } from "./gemini.js";
|
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { getConfig } from "./config.js";
|
|
2
|
+
import { SpanBuilder, SpanData, SpanType, TraceBuilder, TraceStatus } from "./trace.js";
|
|
3
|
+
import { createTransport, Transport } from "./transport.js";
|
|
4
|
+
import { installGeminiInterceptor } from "./interceptors/gemini.js";
|
|
5
|
+
|
|
6
|
+
export interface RecordOptions {
|
|
7
|
+
name?: string;
|
|
8
|
+
input?: unknown;
|
|
9
|
+
metadata?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TraceRecorder {
|
|
13
|
+
private builder: TraceBuilder;
|
|
14
|
+
private transport: Transport;
|
|
15
|
+
private interceptorsInstalled = false;
|
|
16
|
+
output: unknown = undefined;
|
|
17
|
+
|
|
18
|
+
constructor(opts?: RecordOptions) {
|
|
19
|
+
this.builder = new TraceBuilder();
|
|
20
|
+
this.transport = createTransport();
|
|
21
|
+
const cfg = getConfig();
|
|
22
|
+
if (cfg.projectId) this.builder.setProjectId(cfg.projectId);
|
|
23
|
+
if (opts?.metadata) this.builder.setMetadata(opts.metadata);
|
|
24
|
+
if (opts?.name || opts?.input) {
|
|
25
|
+
this.builder.start(opts.name, opts.input);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get traceId() { return this.builder.id; }
|
|
30
|
+
|
|
31
|
+
start(name?: string, input?: unknown): this {
|
|
32
|
+
this.builder.start(name, input);
|
|
33
|
+
this.installInterceptors();
|
|
34
|
+
this.transport.send("trace_started", this.builder.toDict() as unknown as Record<string, unknown>);
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
end(output?: unknown, status: TraceStatus = TraceStatus.COMPLETED) {
|
|
39
|
+
if (output !== undefined) this.output = output;
|
|
40
|
+
const data = this.builder.end(this.output, status);
|
|
41
|
+
this.transport.send("trace_ended", {
|
|
42
|
+
id: data.id,
|
|
43
|
+
ended_at: data.ended_at!,
|
|
44
|
+
output: data.output,
|
|
45
|
+
status: data.status,
|
|
46
|
+
total_tokens: data.total_tokens,
|
|
47
|
+
total_cost: data.total_cost,
|
|
48
|
+
});
|
|
49
|
+
this.transport.close();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addSpan(span: SpanData) {
|
|
53
|
+
span.trace_id = this.builder.id;
|
|
54
|
+
this.builder.addSpan(span);
|
|
55
|
+
this.transport.send("span_started", span as unknown as Record<string, unknown>);
|
|
56
|
+
if (span.ended_at) {
|
|
57
|
+
this.transport.send("span_ended", {
|
|
58
|
+
id: span.id,
|
|
59
|
+
ended_at: span.ended_at,
|
|
60
|
+
output: span.output,
|
|
61
|
+
output_tokens: span.output_tokens,
|
|
62
|
+
cost: span.cost,
|
|
63
|
+
error: span.error,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
startSpan(name: string, spanType: SpanType = SpanType.LLM_CALL, input?: unknown, model?: string, parentId?: string): SpanBuilder {
|
|
69
|
+
const sb = new SpanBuilder(name, spanType).start();
|
|
70
|
+
sb.setTraceId(this.builder.id);
|
|
71
|
+
if (input !== undefined) sb.setInput(input);
|
|
72
|
+
if (model) sb.setModel(model);
|
|
73
|
+
if (parentId) sb.setParentId(parentId);
|
|
74
|
+
this.transport.send("span_started", sb.toData() as unknown as Record<string, unknown>);
|
|
75
|
+
return sb;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
endSpan(spanBuilder: SpanBuilder, output?: unknown, error?: string) {
|
|
79
|
+
const span = spanBuilder.end(output, error);
|
|
80
|
+
span.trace_id = this.builder.id;
|
|
81
|
+
this.builder.addSpan(span);
|
|
82
|
+
this.transport.send("span_ended", {
|
|
83
|
+
id: span.id,
|
|
84
|
+
ended_at: span.ended_at,
|
|
85
|
+
output: span.output,
|
|
86
|
+
output_tokens: span.output_tokens,
|
|
87
|
+
cost: span.cost,
|
|
88
|
+
error: span.error,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private installInterceptors() {
|
|
93
|
+
if (this.interceptorsInstalled) return;
|
|
94
|
+
installGeminiInterceptor((span) => this.addSpan(span));
|
|
95
|
+
this.interceptorsInstalled = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function record(opts?: RecordOptions): TraceRecorder {
|
|
100
|
+
const cfg = getConfig();
|
|
101
|
+
if (!cfg.enabled) {
|
|
102
|
+
// Return a no-op recorder
|
|
103
|
+
return new TraceRecorder(opts);
|
|
104
|
+
}
|
|
105
|
+
return new TraceRecorder(opts);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function trace<T>(fn: (...args: unknown[]) => T, opts?: RecordOptions): (...args: unknown[]) => T {
|
|
109
|
+
const cfg = getConfig();
|
|
110
|
+
return (...args: unknown[]): T => {
|
|
111
|
+
if (!cfg.enabled) return fn(...args);
|
|
112
|
+
|
|
113
|
+
const recorder = new TraceRecorder({
|
|
114
|
+
name: opts?.name || fn.name || "anonymous",
|
|
115
|
+
input: opts?.input ?? args,
|
|
116
|
+
metadata: opts?.metadata,
|
|
117
|
+
});
|
|
118
|
+
recorder.start(opts?.name || fn.name || "anonymous", opts?.input ?? args);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = fn(...args);
|
|
122
|
+
// Handle async functions
|
|
123
|
+
if (result && typeof (result as { then?: unknown }).then === "function") {
|
|
124
|
+
return (result as unknown as Promise<unknown>).then(
|
|
125
|
+
(resolved) => { recorder.end(resolved, TraceStatus.COMPLETED); return resolved; },
|
|
126
|
+
(err) => { recorder.end(undefined, TraceStatus.FAILED); throw err; }
|
|
127
|
+
) as unknown as T;
|
|
128
|
+
}
|
|
129
|
+
recorder.end(result, TraceStatus.COMPLETED);
|
|
130
|
+
return result;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
recorder.end(undefined, TraceStatus.FAILED);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { genId, nowIso, utcNow } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
export enum SpanType {
|
|
4
|
+
LLM_CALL = "llm_call",
|
|
5
|
+
TOOL_CALL = "tool_call",
|
|
6
|
+
TOOL_RESULT = "tool_result",
|
|
7
|
+
REASONING = "reasoning",
|
|
8
|
+
ACTION = "action",
|
|
9
|
+
ERROR = "error",
|
|
10
|
+
FORK_POINT = "fork_point",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum TraceStatus {
|
|
14
|
+
RUNNING = "running",
|
|
15
|
+
COMPLETED = "completed",
|
|
16
|
+
FAILED = "failed",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SpanData {
|
|
20
|
+
id: string;
|
|
21
|
+
trace_id: string;
|
|
22
|
+
parent_id: string | null;
|
|
23
|
+
span_type: string;
|
|
24
|
+
name: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
input?: unknown;
|
|
27
|
+
output?: unknown;
|
|
28
|
+
input_tokens?: number;
|
|
29
|
+
output_tokens?: number;
|
|
30
|
+
cost?: number;
|
|
31
|
+
duration_ms?: number;
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
33
|
+
started_at: string;
|
|
34
|
+
ended_at?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TraceData {
|
|
39
|
+
id: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
input?: unknown;
|
|
42
|
+
output?: unknown;
|
|
43
|
+
status: string;
|
|
44
|
+
total_tokens: number;
|
|
45
|
+
total_cost: number;
|
|
46
|
+
total_duration_ms: number;
|
|
47
|
+
metadata?: Record<string, unknown>;
|
|
48
|
+
started_at: string;
|
|
49
|
+
ended_at?: string;
|
|
50
|
+
spans?: SpanData[];
|
|
51
|
+
project_id?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SpanBuilder {
|
|
55
|
+
private data: Partial<SpanData> & { id: string; span_type: string; name: string };
|
|
56
|
+
private _startTime?: Date;
|
|
57
|
+
|
|
58
|
+
constructor(name: string, spanType: SpanType) {
|
|
59
|
+
this.data = { id: genId(), span_type: spanType, name, trace_id: "", parent_id: null, started_at: nowIso() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setModel(model: string) { this.data.model = model; return this; }
|
|
63
|
+
setInput(input: unknown) { this.data.input = input; return this; }
|
|
64
|
+
setOutput(output: unknown) { this.data.output = output; return this; }
|
|
65
|
+
setParentId(id: string) { this.data.parent_id = id; return this; }
|
|
66
|
+
setTraceId(id: string) { this.data.trace_id = id; return this; }
|
|
67
|
+
setMetadata(m: Record<string, unknown>) { this.data.metadata = m; return this; }
|
|
68
|
+
|
|
69
|
+
start(): this {
|
|
70
|
+
this._startTime = utcNow();
|
|
71
|
+
this.data.started_at = this._startTime.toISOString();
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
end(output?: unknown, error?: string): SpanData {
|
|
76
|
+
const now = utcNow();
|
|
77
|
+
this.data.ended_at = now.toISOString();
|
|
78
|
+
if (output !== undefined) this.data.output = output;
|
|
79
|
+
if (error) this.data.error = error;
|
|
80
|
+
if (this._startTime) {
|
|
81
|
+
this.data.duration_ms = now.getTime() - this._startTime.getTime();
|
|
82
|
+
}
|
|
83
|
+
return this.data as SpanData;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get id() { return this.data.id; }
|
|
87
|
+
toData(): SpanData { return this.data as SpanData; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class TraceBuilder {
|
|
91
|
+
private data: TraceData;
|
|
92
|
+
private _startTime?: Date;
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
this.data = {
|
|
96
|
+
id: genId(),
|
|
97
|
+
status: TraceStatus.RUNNING,
|
|
98
|
+
total_tokens: 0,
|
|
99
|
+
total_cost: 0,
|
|
100
|
+
total_duration_ms: 0,
|
|
101
|
+
started_at: nowIso(),
|
|
102
|
+
spans: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
start(name?: string, input?: unknown): this {
|
|
107
|
+
this._startTime = utcNow();
|
|
108
|
+
this.data.started_at = this._startTime.toISOString();
|
|
109
|
+
if (name) this.data.name = name;
|
|
110
|
+
if (input !== undefined) this.data.input = input;
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
end(output?: unknown, status: TraceStatus = TraceStatus.COMPLETED): TraceData {
|
|
115
|
+
const now = utcNow();
|
|
116
|
+
this.data.ended_at = now.toISOString();
|
|
117
|
+
this.data.status = status;
|
|
118
|
+
if (output !== undefined) this.data.output = output;
|
|
119
|
+
if (this._startTime) {
|
|
120
|
+
this.data.total_duration_ms = now.getTime() - this._startTime.getTime();
|
|
121
|
+
}
|
|
122
|
+
return this.data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
addSpan(span: SpanData) {
|
|
126
|
+
this.data.spans!.push(span);
|
|
127
|
+
this.data.total_tokens += (span.input_tokens || 0) + (span.output_tokens || 0);
|
|
128
|
+
this.data.total_cost += span.cost || 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get id() { return this.data.id; }
|
|
132
|
+
setProjectId(id: string) { this.data.project_id = id; }
|
|
133
|
+
setMetadata(m: Record<string, unknown>) { this.data.metadata = m; }
|
|
134
|
+
toDict(): TraceData { return this.data; }
|
|
135
|
+
}
|