0xtrace 1.0.0 → 1.0.1

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/index.js CHANGED
@@ -1 +1,430 @@
1
- "use strict";var s=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var i=Object.prototype.hasOwnProperty;var l=(r,e)=>{for(var t in e)s(r,t,{get:e[t],enumerable:!0})},m=(r,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of c(e))!i.call(r,o)&&o!==t&&s(r,o,{get:()=>e[o],enumerable:!(n=a(e,o))||n.enumerable});return r};var S=r=>m(s({},"__esModule",{value:!0}),r);var u={};l(u,{traceLlmCall:()=>d});module.exports=S(u);async function d(r){let e=process.env.UPSTASH_REDIS_REST_URL,t=process.env.UPSTASH_REDIS_REST_TOKEN;if(!(!e||!t))try{fetch(`${e}/lpush/0xtrace_queue`,{method:"POST",headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},body:JSON.stringify({...r,timestamp:new Date().toISOString()})}).catch(n=>console.error(n))}catch(n){console.error(n)}}0&&(module.exports={traceLlmCall});
1
+ var __defProp = Object.defineProperty;
2
+ var __defProps = Object.defineProperties;
3
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
7
+ var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name);
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __spreadValues = (a, b) => {
10
+ for (var prop in b || (b = {}))
11
+ if (__hasOwnProp.call(b, prop))
12
+ __defNormalProp(a, prop, b[prop]);
13
+ if (__getOwnPropSymbols)
14
+ for (var prop of __getOwnPropSymbols(b)) {
15
+ if (__propIsEnum.call(b, prop))
16
+ __defNormalProp(a, prop, b[prop]);
17
+ }
18
+ return a;
19
+ };
20
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
21
+ var __await = function(promise, isYieldStar) {
22
+ this[0] = promise;
23
+ this[1] = isYieldStar;
24
+ };
25
+ var __asyncGenerator = (__this, __arguments, generator) => {
26
+ var resume = (k, v, yes, no) => {
27
+ try {
28
+ var x = generator[k](v), isAwait = (v = x.value) instanceof __await, done = x.done;
29
+ Promise.resolve(isAwait ? v[0] : v).then((y) => isAwait ? resume(k === "return" ? k : "next", v[1] ? { done: y.done, value: y.value } : y, yes, no) : yes({ value: y, done })).catch((e) => resume("throw", e, yes, no));
30
+ } catch (e) {
31
+ no(e);
32
+ }
33
+ }, method = (k, call, wait, clear) => it[k] = (x) => (call = new Promise((yes, no, run) => (run = () => resume(k, x, yes, no), q ? q.then(run) : run())), clear = () => q === wait && (q = 0), q = wait = call.then(clear, clear), call), q, it = {};
34
+ return generator = generator.apply(__this, __arguments), it[__knownSymbol("asyncIterator")] = () => it, method("next"), method("throw"), method("return"), it;
35
+ };
36
+ var __forAwait = (obj, it, method) => (it = obj[__knownSymbol("asyncIterator")]) ? it.call(obj) : (obj = obj[__knownSymbol("iterator")](), it = {}, method = (key, fn) => (fn = obj[key]) && (it[key] = (arg) => new Promise((yes, no, done) => (arg = fn.call(obj, arg), done = arg.done, Promise.resolve(arg.value).then((value) => yes({ value, done }), no)))), method("next"), method("return"), it);
37
+
38
+ // src/core/dispatcher.ts
39
+ var DEFAULT_BATCH_SIZE = 10;
40
+ var DEFAULT_FLUSH_MS = 2e3;
41
+ var DEFAULT_TIMEOUT_MS = 5e3;
42
+ var MAX_RETRY_ATTEMPTS = 3;
43
+ var BASE_RETRY_DELAY_MS = 200;
44
+ var Dispatcher = class {
45
+ constructor(opts) {
46
+ /** The in-memory buffer accumulating payloads between flushes. */
47
+ this.buffer = [];
48
+ /** The NodeJS/browser timer handle for the periodic flush. */
49
+ this.flushTimer = null;
50
+ /** Tracks all in-flight fetch Promises so flush() can await them. */
51
+ this.inFlight = /* @__PURE__ */ new Set();
52
+ var _a, _b, _c, _d;
53
+ this.ingestUrl = opts.ingestUrl;
54
+ this.apiKey = opts.apiKey;
55
+ this.timeoutMs = (_a = opts.timeoutMs) != null ? _a : DEFAULT_TIMEOUT_MS;
56
+ this.batchSize = (_b = opts.batchSize) != null ? _b : DEFAULT_BATCH_SIZE;
57
+ this.onError = (_c = opts.onError) != null ? _c : ((err, payloads) => {
58
+ console.warn(
59
+ `[PromptTracer] Failed to deliver ${payloads.length} trace(s):`,
60
+ err.message
61
+ );
62
+ });
63
+ const intervalMs = (_d = opts.flushIntervalMs) != null ? _d : DEFAULT_FLUSH_MS;
64
+ this.flushTimer = setInterval(() => {
65
+ if (this.buffer.length > 0) {
66
+ this._drainBuffer();
67
+ }
68
+ }, intervalMs);
69
+ if (typeof this.flushTimer === "object" && typeof this.flushTimer.unref === "function") {
70
+ this.flushTimer.unref();
71
+ }
72
+ }
73
+ // ── Public API ─────────────────────────────────────────────────────────────
74
+ /**
75
+ * Accepts a payload and schedules delivery non-blocking via the microtask
76
+ * queue. The caller returns immediately; the POST happens asynchronously.
77
+ */
78
+ send(payload) {
79
+ Promise.resolve().then(() => {
80
+ this.buffer.push(payload);
81
+ if (this.buffer.length >= this.batchSize) {
82
+ this._drainBuffer();
83
+ }
84
+ });
85
+ }
86
+ /**
87
+ * Waits for all in-flight requests and flushes any remaining buffered
88
+ * payloads. Call this in tests or on process shutdown.
89
+ *
90
+ * @example
91
+ * process.on('SIGTERM', () => tracer.flush());
92
+ */
93
+ async flush() {
94
+ if (this.buffer.length > 0) {
95
+ this._drainBuffer();
96
+ }
97
+ if (this.inFlight.size > 0) {
98
+ await Promise.allSettled([...this.inFlight]);
99
+ }
100
+ }
101
+ /**
102
+ * Stops the periodic flush timer and flushes remaining payloads.
103
+ * Call when the Tracer instance is being torn down.
104
+ */
105
+ async destroy() {
106
+ if (this.flushTimer !== null) {
107
+ clearInterval(this.flushTimer);
108
+ this.flushTimer = null;
109
+ }
110
+ await this.flush();
111
+ }
112
+ // ── Private ────────────────────────────────────────────────────────────────
113
+ /**
114
+ * Atomically snapshots and clears the buffer, then initiates an async
115
+ * POST. Multiple concurrent drains are safe — each works on its own slice.
116
+ */
117
+ _drainBuffer() {
118
+ const batch = this.buffer.splice(0, this.buffer.length);
119
+ if (batch.length === 0) return;
120
+ const promise = this._sendWithRetry(batch, 1).finally(() => {
121
+ this.inFlight.delete(promise);
122
+ });
123
+ this.inFlight.add(promise);
124
+ }
125
+ /**
126
+ * Attempts to POST a batch to the ingest endpoint.
127
+ * Retries up to MAX_RETRY_ATTEMPTS times with exponential back-off.
128
+ * Only retries on network errors or 5xx responses.
129
+ */
130
+ async _sendWithRetry(batch, attempt) {
131
+ try {
132
+ await this._post(batch);
133
+ } catch (err) {
134
+ const error = err instanceof Error ? err : new Error(String(err));
135
+ if (attempt < MAX_RETRY_ATTEMPTS) {
136
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
137
+ await sleep(delay);
138
+ return this._sendWithRetry(batch, attempt + 1);
139
+ }
140
+ this.onError(error, batch);
141
+ }
142
+ }
143
+ /**
144
+ * Performs the raw HTTP POST with an AbortController timeout.
145
+ * Throws on network failure or non-2xx status.
146
+ */
147
+ async _post(batch) {
148
+ const controller = new AbortController();
149
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
150
+ const headers = {
151
+ "Content-Type": "application/json"
152
+ };
153
+ if (this.apiKey) {
154
+ headers["x-api-key"] = this.apiKey;
155
+ }
156
+ let response;
157
+ try {
158
+ response = await fetch(this.ingestUrl, {
159
+ method: "POST",
160
+ headers,
161
+ body: JSON.stringify({ traces: batch }),
162
+ signal: controller.signal
163
+ });
164
+ } finally {
165
+ clearTimeout(timer);
166
+ }
167
+ if (!response.ok) {
168
+ if (response.status >= 500) {
169
+ throw new Error(`Ingest endpoint returned ${response.status}`);
170
+ }
171
+ console.warn(
172
+ `[PromptTracer] Ingest rejected batch (${response.status}). Discarding ${batch.length} trace(s).`
173
+ );
174
+ }
175
+ }
176
+ // ── Helpers ──────────────────────────────────────────────────────────────────
177
+ };
178
+ function sleep(ms) {
179
+ return new Promise((resolve) => setTimeout(resolve, ms));
180
+ }
181
+
182
+ // src/utils/cost.ts
183
+ var MODEL_PRICES = {
184
+ // ── OpenAI ──────────────────────────────────────────────────────────────
185
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10 },
186
+ "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6 },
187
+ "gpt-4-turbo": { inputPer1M: 10, outputPer1M: 30 },
188
+ "gpt-4": { inputPer1M: 30, outputPer1M: 60 },
189
+ "gpt-3.5-turbo": { inputPer1M: 0.5, outputPer1M: 1.5 },
190
+ "o1": { inputPer1M: 15, outputPer1M: 60 },
191
+ "o1-mini": { inputPer1M: 3, outputPer1M: 12 },
192
+ "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
193
+ // ── Anthropic ───────────────────────────────────────────────────────────
194
+ "claude-opus-4": { inputPer1M: 15, outputPer1M: 75 },
195
+ "claude-sonnet-4": { inputPer1M: 3, outputPer1M: 15 },
196
+ "claude-haiku-4": { inputPer1M: 0.8, outputPer1M: 4 },
197
+ "claude-3-5-sonnet": { inputPer1M: 3, outputPer1M: 15 },
198
+ "claude-3-5-haiku": { inputPer1M: 0.8, outputPer1M: 4 },
199
+ "claude-3-opus": { inputPer1M: 15, outputPer1M: 75 },
200
+ // ── Google ──────────────────────────────────────────────────────────────
201
+ "gemini-1.5-pro": { inputPer1M: 3.5, outputPer1M: 10.5 },
202
+ "gemini-1.5-flash": { inputPer1M: 0.35, outputPer1M: 1.05 },
203
+ "gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4 }
204
+ };
205
+ var UNKNOWN_PRICE = { inputPer1M: 0, outputPer1M: 0 };
206
+ function resolvePrice(model) {
207
+ const normalised = model.toLowerCase().trim();
208
+ if (normalised in MODEL_PRICES) return MODEL_PRICES[normalised];
209
+ for (const key of Object.keys(MODEL_PRICES)) {
210
+ if (normalised.startsWith(key)) return MODEL_PRICES[key];
211
+ }
212
+ return UNKNOWN_PRICE;
213
+ }
214
+ function calcCostUsd({ model, tokensIn, tokensOut }) {
215
+ const price = resolvePrice(model);
216
+ const inputCost = tokensIn / 1e6 * price.inputPer1M;
217
+ const outputCost = tokensOut / 1e6 * price.outputPer1M;
218
+ return Math.round((inputCost + outputCost) * 1e8) / 1e8;
219
+ }
220
+ function formatCostUsd(usd) {
221
+ if (usd === 0) return "$0.00";
222
+ if (usd < 1e-4) return `$${usd.toFixed(8).replace(/0+$/, "")}`;
223
+ if (usd < 0.01) return `$${usd.toFixed(6).replace(/0+$/, "")}`;
224
+ return `$${usd.toFixed(4)}`;
225
+ }
226
+
227
+ // src/core/tracer.ts
228
+ var SDK_VERSION = "0.1.0";
229
+ function uuid() {
230
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
231
+ return crypto.randomUUID();
232
+ }
233
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
234
+ const r = Math.random() * 16 | 0;
235
+ const v = c === "x" ? r : r & 3 | 8;
236
+ return v.toString(16);
237
+ });
238
+ }
239
+ var Tracer = class {
240
+ constructor(opts, dispatcher) {
241
+ /**
242
+ * Monotonically increasing step counter.
243
+ * Step 1 → first call in the session (triggers full snapshot in the DB).
244
+ * Step N → subsequent calls (store diff only).
245
+ */
246
+ this.stepCounter = 0;
247
+ var _a, _b, _c;
248
+ this.sessionId = (_a = opts.sessionId) != null ? _a : uuid();
249
+ this.metadata = (_b = opts.metadata) != null ? _b : {};
250
+ this.enabled = (_c = opts.enabled) != null ? _c : true;
251
+ this.dispatcher = dispatcher != null ? dispatcher : new Dispatcher({
252
+ ingestUrl: opts.ingestUrl,
253
+ apiKey: opts.apiKey,
254
+ timeoutMs: opts.timeoutMs,
255
+ onError: opts.onError ? (err, payloads) => payloads.forEach((p) => opts.onError(err, p)) : void 0
256
+ });
257
+ }
258
+ // ── Public API ──────────────────────────────────────────────────────────────
259
+ /**
260
+ * The method called by every SDK wrapper after intercepting an LLM call.
261
+ *
262
+ * Design contract:
263
+ * - NEVER awaited by the wrapper; fire-and-forget on microtask queue.
264
+ * - Returns void so the wrapper cannot accidentally `await` it.
265
+ *
266
+ * @example
267
+ * // Inside wrappers/openai.ts — after receiving the result:
268
+ * tracer.captureAsync({ prompt, response, model, tokensIn, tokensOut, latencyMs, isStream });
269
+ */
270
+ captureAsync(raw) {
271
+ if (!this.enabled) return;
272
+ Promise.resolve().then(() => {
273
+ try {
274
+ const payload = this._enrich(raw);
275
+ this.dispatcher.send(payload);
276
+ } catch (err) {
277
+ console.warn("[PromptTracer] Failed to enrich payload:", err);
278
+ }
279
+ });
280
+ }
281
+ /**
282
+ * Returns the step index the *next* call will be assigned.
283
+ * Useful for callers who need to know if this is step 1 (full snapshot)
284
+ * vs. a later step (diff only) before making the LLM call.
285
+ */
286
+ get nextStepIndex() {
287
+ return this.stepCounter + 1;
288
+ }
289
+ /**
290
+ * Waits for all buffered and in-flight payloads to be delivered.
291
+ * Call before process exit or at the end of integration tests.
292
+ *
293
+ * @example
294
+ * afterAll(async () => { await tracer.flush(); });
295
+ */
296
+ async flush() {
297
+ await this.dispatcher.flush();
298
+ }
299
+ // ── Private ─────────────────────────────────────────────────────────────────
300
+ /**
301
+ * Takes a raw capture from the wrapper and enriches it with:
302
+ * - a unique callId
303
+ * - the session's sessionId
304
+ * - a monotonic stepIndex
305
+ * - ISO-8601 timestamp
306
+ * - USD cost estimate
307
+ * - SDK version string
308
+ * - caller metadata
309
+ */
310
+ _enrich(raw) {
311
+ var _a, _b;
312
+ this.stepCounter += 1;
313
+ const estimatedCostUsd = calcCostUsd({
314
+ model: raw.model,
315
+ tokensIn: (_a = raw.tokensIn) != null ? _a : 0,
316
+ tokensOut: (_b = raw.tokensOut) != null ? _b : 0
317
+ });
318
+ return __spreadValues(__spreadProps(__spreadValues({
319
+ // ── Core identity ───────────────────────────────────────────────────
320
+ callId: uuid(),
321
+ sessionId: this.sessionId,
322
+ stepIndex: this.stepCounter,
323
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
324
+ }, raw), {
325
+ // ── Enrichment ───────────────────────────────────────────────────────
326
+ estimatedCostUsd,
327
+ sdkVersion: SDK_VERSION
328
+ }), Object.keys(this.metadata).length > 0 ? { metadata: this.metadata } : {});
329
+ }
330
+ };
331
+
332
+ // src/wrappers/openai.ts
333
+ function wrapOpenAI(client, tracer) {
334
+ return new Proxy(client, {
335
+ get(target, prop, receiver) {
336
+ if (prop === "chat") {
337
+ return new Proxy(target.chat, {
338
+ get(chatTarget, chatProp, chatReceiver) {
339
+ if (chatProp === "completions") {
340
+ return new Proxy(chatTarget.completions, {
341
+ get(compTarget, compProp, compReceiver) {
342
+ if (compProp === "create") {
343
+ return _makeCreateInterceptor(compTarget, tracer);
344
+ }
345
+ return Reflect.get(compTarget, compProp, compReceiver);
346
+ }
347
+ });
348
+ }
349
+ return Reflect.get(chatTarget, chatProp, chatReceiver);
350
+ }
351
+ });
352
+ }
353
+ return Reflect.get(target, prop, receiver);
354
+ }
355
+ });
356
+ }
357
+ function _makeCreateInterceptor(compTarget, tracer) {
358
+ async function create(params) {
359
+ var _a, _b, _c, _d, _e;
360
+ const startMs = Date.now();
361
+ if (params.stream === true) {
362
+ const stream = await compTarget.create(params);
363
+ return _wrapStream(stream, params, startMs, tracer);
364
+ }
365
+ const result = await compTarget.create(params);
366
+ const latencyMs = Date.now() - startMs;
367
+ tracer.captureAsync({
368
+ prompt: params.messages,
369
+ response: (_c = (_b = (_a = result.choices[0]) == null ? void 0 : _a.message) == null ? void 0 : _b.content) != null ? _c : "",
370
+ model: params.model,
371
+ tokensIn: (_d = result.usage) == null ? void 0 : _d.prompt_tokens,
372
+ tokensOut: (_e = result.usage) == null ? void 0 : _e.completion_tokens,
373
+ latencyMs,
374
+ isStream: false
375
+ });
376
+ return result;
377
+ }
378
+ return create;
379
+ }
380
+ function _wrapStream(stream, params, startMs, tracer) {
381
+ return __asyncGenerator(this, null, function* () {
382
+ var _a, _b, _c, _d;
383
+ let fullContent = "";
384
+ let chunkCount = 0;
385
+ let promptTokens;
386
+ try {
387
+ try {
388
+ for (var iter = __forAwait(stream), more, temp, error; more = !(temp = yield new __await(iter.next())).done; more = false) {
389
+ const chunk = temp.value;
390
+ if (((_a = chunk.usage) == null ? void 0 : _a.prompt_tokens) !== void 0) {
391
+ promptTokens = chunk.usage.prompt_tokens;
392
+ }
393
+ const delta = (_d = (_c = (_b = chunk.choices[0]) == null ? void 0 : _b.delta) == null ? void 0 : _c.content) != null ? _d : "";
394
+ fullContent += delta;
395
+ chunkCount += 1;
396
+ yield chunk;
397
+ }
398
+ } catch (temp) {
399
+ error = [temp];
400
+ } finally {
401
+ try {
402
+ more && (temp = iter.return) && (yield new __await(temp.call(iter)));
403
+ } finally {
404
+ if (error)
405
+ throw error[0];
406
+ }
407
+ }
408
+ } finally {
409
+ const latencyMs = Date.now() - startMs;
410
+ tracer.captureAsync({
411
+ prompt: params.messages,
412
+ response: fullContent,
413
+ model: params.model,
414
+ tokensIn: promptTokens,
415
+ // exact if include_usage was set
416
+ tokensOut: chunkCount,
417
+ // approximation: 1 chunk ≈ 1 token
418
+ latencyMs,
419
+ isStream: true
420
+ });
421
+ }
422
+ });
423
+ }
424
+ export {
425
+ Dispatcher,
426
+ Tracer,
427
+ calcCostUsd,
428
+ formatCostUsd,
429
+ wrapOpenAI
430
+ };
package/package.json CHANGED
@@ -1,18 +1,23 @@
1
1
  {
2
2
  "name": "0xtrace",
3
- "version": "1.0.0",
4
- "description": "",
5
- "main": "./dist/index.js",
6
- "module": "./dist/index.mjs",
7
- "types": "./dist/index.d.ts",
3
+ "version": "1.0.1",
4
+ "description": "Proxy-based LLM telemetry SDK for 0xtrace",
5
+ "type": "module",
6
+ "main": "index.js",
8
7
  "scripts": {
9
- "build": "tsup",
10
- "prepublishOnly": "npm run build"
8
+ "test": "echo \"Error: no test specified\" && exit 1",
9
+ "build": "tsup"
10
+ },
11
+ "keywords": [],
12
+ "author": "",
13
+ "license": "ISC",
14
+ "dependencies": {
15
+ "diff": "^9.0.0",
16
+ "openai": "^6.38.0"
11
17
  },
12
- "author": "Sidhant Kumar",
13
- "license": "MIT",
14
18
  "devDependencies": {
15
- "@types/node": "^25.9.1",
19
+ "@types/node": "^25.9.0",
20
+ "cac": "^6.7.14",
16
21
  "tsup": "^8.5.1",
17
22
  "typescript": "^6.0.3"
18
23
  }
@@ -0,0 +1,212 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // packages/sdk/src/core/dispatcher.ts
3
+ //
4
+ // Responsibilities:
5
+ // 1. Accept TracePayload objects from the Tracer via a fire-and-forget API.
6
+ // 2. Accumulate payloads in an in-memory micro-batch.
7
+ // 3. Flush the batch to the ingest endpoint as a single POST request.
8
+ // 4. Retry failed requests with exponential back-off (max 3 attempts).
9
+ // 5. NEVER block the caller's thread — every send goes onto the microtask
10
+ // queue via Promise.resolve().then(...)
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ import type { IDispatcher, TracePayload } from "./types";
14
+
15
+ // ── Constants ────────────────────────────────────────────────────────────────
16
+
17
+ const DEFAULT_BATCH_SIZE = 10; // flush after N payloads accumulate
18
+ const DEFAULT_FLUSH_MS = 2_000; // flush every 2 s even if batch isn't full
19
+ const DEFAULT_TIMEOUT_MS = 5_000; // per-request abort timeout
20
+ const MAX_RETRY_ATTEMPTS = 3;
21
+ const BASE_RETRY_DELAY_MS = 200; // doubles on each retry (200 → 400 → 800)
22
+
23
+ // ── Types ────────────────────────────────────────────────────────────────────
24
+
25
+ export interface DispatcherOptions {
26
+ ingestUrl: string;
27
+ apiKey?: string;
28
+ timeoutMs?: number;
29
+ batchSize?: number;
30
+ flushIntervalMs?: number;
31
+ onError?: (error: Error, payloads: TracePayload[]) => void;
32
+ }
33
+
34
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
35
+
36
+ export class Dispatcher implements IDispatcher {
37
+ private readonly ingestUrl: string;
38
+ private readonly apiKey: string | undefined;
39
+ private readonly timeoutMs: number;
40
+ private readonly batchSize: number;
41
+ private readonly onError: (error: Error, payloads: TracePayload[]) => void;
42
+
43
+ /** The in-memory buffer accumulating payloads between flushes. */
44
+ private buffer: TracePayload[] = [];
45
+
46
+ /** The NodeJS/browser timer handle for the periodic flush. */
47
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
48
+
49
+ /** Tracks all in-flight fetch Promises so flush() can await them. */
50
+ private inFlight = new Set<Promise<void>>();
51
+
52
+ constructor(opts: DispatcherOptions) {
53
+ this.ingestUrl = opts.ingestUrl;
54
+ this.apiKey = opts.apiKey;
55
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
57
+
58
+ this.onError = opts.onError ?? ((err, payloads) => {
59
+ console.warn(
60
+ `[PromptTracer] Failed to deliver ${payloads.length} trace(s):`,
61
+ err.message
62
+ );
63
+ });
64
+
65
+ // Start the periodic flush timer.
66
+ const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_MS;
67
+ this.flushTimer = setInterval(() => {
68
+ if (this.buffer.length > 0) {
69
+ this._drainBuffer();
70
+ }
71
+ }, intervalMs);
72
+
73
+ // Prevent the timer from keeping a Node process alive indefinitely.
74
+ if (typeof this.flushTimer === "object" && typeof (this.flushTimer as NodeJS.Timeout).unref === "function") {
75
+ (this.flushTimer as NodeJS.Timeout).unref();
76
+ }
77
+ }
78
+
79
+ // ── Public API ─────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Accepts a payload and schedules delivery non-blocking via the microtask
83
+ * queue. The caller returns immediately; the POST happens asynchronously.
84
+ */
85
+ send(payload: TracePayload): void {
86
+ // Schedule the actual buffer-push on the microtask queue so it never
87
+ // adds synchronous overhead to the intercepted LLM call path.
88
+ Promise.resolve().then(() => {
89
+ this.buffer.push(payload);
90
+
91
+ if (this.buffer.length >= this.batchSize) {
92
+ this._drainBuffer();
93
+ }
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Waits for all in-flight requests and flushes any remaining buffered
99
+ * payloads. Call this in tests or on process shutdown.
100
+ *
101
+ * @example
102
+ * process.on('SIGTERM', () => tracer.flush());
103
+ */
104
+ async flush(): Promise<void> {
105
+ // Flush whatever is sitting in the buffer right now.
106
+ if (this.buffer.length > 0) {
107
+ this._drainBuffer();
108
+ }
109
+
110
+ // Await all in-flight POSTs.
111
+ if (this.inFlight.size > 0) {
112
+ await Promise.allSettled([...this.inFlight]);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Stops the periodic flush timer and flushes remaining payloads.
118
+ * Call when the Tracer instance is being torn down.
119
+ */
120
+ async destroy(): Promise<void> {
121
+ if (this.flushTimer !== null) {
122
+ clearInterval(this.flushTimer);
123
+ this.flushTimer = null;
124
+ }
125
+ await this.flush();
126
+ }
127
+
128
+ // ── Private ────────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Atomically snapshots and clears the buffer, then initiates an async
132
+ * POST. Multiple concurrent drains are safe — each works on its own slice.
133
+ */
134
+ private _drainBuffer(): void {
135
+ const batch = this.buffer.splice(0, this.buffer.length);
136
+ if (batch.length === 0) return;
137
+
138
+ const promise = this._sendWithRetry(batch, 1).finally(() => {
139
+ this.inFlight.delete(promise);
140
+ });
141
+
142
+ this.inFlight.add(promise);
143
+ }
144
+
145
+ /**
146
+ * Attempts to POST a batch to the ingest endpoint.
147
+ * Retries up to MAX_RETRY_ATTEMPTS times with exponential back-off.
148
+ * Only retries on network errors or 5xx responses.
149
+ */
150
+ private async _sendWithRetry(
151
+ batch: TracePayload[],
152
+ attempt: number
153
+ ): Promise<void> {
154
+ try {
155
+ await this._post(batch);
156
+ } catch (err) {
157
+ const error = err instanceof Error ? err : new Error(String(err));
158
+
159
+ if (attempt < MAX_RETRY_ATTEMPTS) {
160
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
161
+ await sleep(delay);
162
+ return this._sendWithRetry(batch, attempt + 1);
163
+ }
164
+
165
+ // All retries exhausted — surface to the error handler.
166
+ this.onError(error, batch);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Performs the raw HTTP POST with an AbortController timeout.
172
+ * Throws on network failure or non-2xx status.
173
+ */
174
+ private async _post(batch: TracePayload[]): Promise<void> {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
177
+
178
+ const headers: Record<string, string> = {
179
+ "Content-Type": "application/json",
180
+ };
181
+
182
+ if (this.apiKey) {
183
+ headers["x-api-key"] = this.apiKey;
184
+ }
185
+
186
+ let response: Response;
187
+ try {
188
+ response = await fetch(this.ingestUrl, {
189
+ method: "POST",
190
+ headers,
191
+ body: JSON.stringify({ traces: batch }),
192
+ signal: controller.signal,
193
+ });
194
+ } finally {
195
+ clearTimeout(timer);
196
+ }
197
+
198
+ if (!response.ok) {
199
+ if (response.status >= 500) {
200
+ throw new Error(`Ingest endpoint returned ${response.status}`);
201
+ }
202
+ console.warn(
203
+ `[PromptTracer] Ingest rejected batch (${response.status}). ` +
204
+ `Discarding ${batch.length} trace(s).`
205
+ );
206
+ }
207
+ }
208
+ // ── Helpers ──────────────────────────────────────────────────────────────────
209
+ }
210
+ function sleep(ms: number): Promise<void> {
211
+ return new Promise((resolve) => setTimeout(resolve, ms));
212
+ }