@syntheticminds/wiretap-llm 0.2.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 ADDED
@@ -0,0 +1,176 @@
1
+ # wiretap-llm
2
+
3
+ Telemetry client for sending LLM logs to your Wiretap instance.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install wiretap-llm
9
+ # or
10
+ bun add wiretap-llm
11
+ ```
12
+
13
+ ## Quick Start - Automatic Instrumentation
14
+
15
+ The easiest way to use wiretap-llm is with `createInstrumentedFetch`. It automatically captures all OpenRouter/OpenAI requests:
16
+
17
+ ```ts
18
+ import OpenAI from "openai";
19
+ import { TelemetryClient, createInstrumentedFetch } from "wiretap-llm";
20
+
21
+ // Create telemetry client
22
+ const telemetry = new TelemetryClient({
23
+ baseUrl: process.env.WIRETAP_BASE_URL!,
24
+ apiKey: process.env.WIRETAP_API_KEY!,
25
+ });
26
+
27
+ // Create OpenAI client with instrumented fetch
28
+ const openrouter = new OpenAI({
29
+ baseURL: "https://openrouter.ai/api/v1",
30
+ apiKey: process.env.OPENROUTER_API_KEY!,
31
+ fetch: createInstrumentedFetch(telemetry),
32
+ });
33
+
34
+ // Use normally - all requests are automatically logged
35
+ const response = await openrouter.chat.completions.create({
36
+ model: "anthropic/claude-sonnet-4",
37
+ messages: [{ role: "user", content: "Hello!" }],
38
+ });
39
+
40
+ // Streaming works too
41
+ const stream = await openrouter.chat.completions.create({
42
+ model: "anthropic/claude-sonnet-4",
43
+ messages: [{ role: "user", content: "Count to 5" }],
44
+ stream: true,
45
+ });
46
+
47
+ for await (const chunk of stream) {
48
+ process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
49
+ }
50
+
51
+ // On app shutdown
52
+ await telemetry.shutdown();
53
+ ```
54
+
55
+ ## Manual Reporting
56
+
57
+ For custom logging scenarios, you can report logs manually:
58
+
59
+ ```ts
60
+ import { TelemetryClient, createId, toIso } from "wiretap-llm";
61
+
62
+ const client = new TelemetryClient({
63
+ baseUrl: process.env.WIRETAP_BASE_URL!,
64
+ apiKey: process.env.WIRETAP_API_KEY!,
65
+ });
66
+
67
+ client.report({
68
+ id: createId(),
69
+ timestamp: toIso(new Date()),
70
+ project: "", // Inferred from API key
71
+ source: "openrouter",
72
+ method: "POST",
73
+ url: "https://openrouter.ai/api/v1/chat/completions",
74
+ path: "/v1/chat/completions",
75
+ model: "anthropic/claude-sonnet-4",
76
+ streamed: false,
77
+ timing: {
78
+ started_at: toIso(startTime),
79
+ ended_at: toIso(endTime),
80
+ duration_ms: endTime.getTime() - startTime.getTime(),
81
+ },
82
+ request: {
83
+ body_json: requestBody,
84
+ },
85
+ response: {
86
+ status: 200,
87
+ body_json: responseBody,
88
+ },
89
+ usage: responseBody.usage,
90
+ });
91
+
92
+ await client.shutdown();
93
+ ```
94
+
95
+ ## Configuration
96
+
97
+ ### TelemetryClient Options
98
+
99
+ | Option | Type | Default | Description |
100
+ |--------|------|---------|-------------|
101
+ | `baseUrl` | string | required | Your Wiretap instance URL |
102
+ | `apiKey` | string | required | Your Wiretap API key |
103
+ | `project` | string | - | Project slug (usually inferred from API key) |
104
+ | `environment` | string | - | Environment name (e.g., "production") |
105
+ | `defaultMetadata` | object | - | Metadata added to all logs |
106
+ | `timeoutMs` | number | 5000 | Request timeout |
107
+ | `maxBatchSize` | number | 25 | Max logs per batch |
108
+ | `flushIntervalMs` | number | 2000 | Auto-flush interval |
109
+ | `maxLogBytes` | number | 1000000 | Max log size before truncation |
110
+ | `maxQueueSize` | number | 1000 | Max queue size (drops oldest when exceeded) |
111
+ | `retryBaseDelayMs` | number | 1000 | Initial retry delay |
112
+ | `retryMaxDelayMs` | number | 30000 | Maximum retry delay |
113
+ | `maxRetries` | number | 5 | Max retries before dropping batch |
114
+ | `onError` | function | - | Error handler callback |
115
+
116
+ ### createInstrumentedFetch Options
117
+
118
+ | Option | Type | Default | Description |
119
+ |--------|------|---------|-------------|
120
+ | `baseFetch` | fetch | globalThis.fetch | Base fetch function to wrap |
121
+ | `defaultMetadata` | object | - | Metadata added to all logs |
122
+ | `source` | string | "openrouter" | Source identifier in logs |
123
+
124
+ ## Reliability Features
125
+
126
+ ### Automatic Retry with Exponential Backoff
127
+
128
+ Failed batches are automatically retried with exponential backoff (1s, 2s, 4s, ...) up to 30s max delay.
129
+
130
+ ### Circuit Breaker
131
+
132
+ After 5 consecutive failures, the circuit breaker opens for 30 seconds. During this time, flushes are skipped to prevent hammering a dead server.
133
+
134
+ ### Queue Limits
135
+
136
+ If the queue exceeds 1000 logs (configurable), oldest logs are dropped to prevent memory exhaustion.
137
+
138
+ ### Graceful Shutdown
139
+
140
+ Call `shutdown()` on app exit to flush remaining logs:
141
+
142
+ ```ts
143
+ process.on("SIGTERM", async () => {
144
+ await telemetry.shutdown();
145
+ process.exit(0);
146
+ });
147
+ ```
148
+
149
+ ## Helper Utilities
150
+
151
+ ```ts
152
+ import { createId, toIso, sanitizeHeaders, safeJsonParse } from "wiretap-llm";
153
+
154
+ // Generate unique ID
155
+ const id = createId(); // "abc123..."
156
+
157
+ // ISO timestamp
158
+ const ts = toIso(new Date()); // "2024-01-20T..."
159
+
160
+ // Remove sensitive headers
161
+ const headers = sanitizeHeaders(req.headers); // Strips auth headers
162
+
163
+ // Safe JSON parse
164
+ const { ok, value } = safeJsonParse(jsonString);
165
+ ```
166
+
167
+ ## Environment Variables
168
+
169
+ ```bash
170
+ WIRETAP_BASE_URL=https://your-wiretap-instance.com
171
+ WIRETAP_API_KEY=wt_your_api_key
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,57 @@
1
+ import type { JsonValue } from "./json";
2
+ import type { TelemetryLog } from "./types/telemetry";
3
+ export type TelemetryClientOptions = {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ /** Optional - project is automatically inferred from API key on server */
7
+ project?: string;
8
+ environment?: string;
9
+ defaultMetadata?: Record<string, JsonValue>;
10
+ timeoutMs?: number;
11
+ maxBatchSize?: number;
12
+ flushIntervalMs?: number;
13
+ maxLogBytes?: number;
14
+ /** Max queue size before dropping oldest logs (default: 1000) */
15
+ maxQueueSize?: number;
16
+ /** Base delay for retry backoff in ms (default: 1000) */
17
+ retryBaseDelayMs?: number;
18
+ /** Max retry delay in ms (default: 30000) */
19
+ retryMaxDelayMs?: number;
20
+ /** Max retries per batch before dropping (default: 5) */
21
+ maxRetries?: number;
22
+ onError?: (err: unknown) => void;
23
+ };
24
+ export declare class TelemetryClient {
25
+ readonly baseUrl: string;
26
+ readonly apiKey: string;
27
+ readonly project?: string;
28
+ readonly environment?: string;
29
+ readonly defaultMetadata?: Record<string, JsonValue>;
30
+ private timeoutMs;
31
+ private maxBatchSize;
32
+ private flushIntervalMs;
33
+ private maxLogBytes;
34
+ private maxQueueSize;
35
+ private retryBaseDelayMs;
36
+ private retryMaxDelayMs;
37
+ private maxRetries;
38
+ private queue;
39
+ private flushing;
40
+ private timer;
41
+ private retryCount;
42
+ private retryDelayMs;
43
+ private circuitOpen;
44
+ private circuitOpenUntil;
45
+ private consecutiveFailures;
46
+ private readonly circuitBreakerThreshold;
47
+ private readonly circuitBreakerDurationMs;
48
+ private onError?;
49
+ constructor(opts: TelemetryClientOptions);
50
+ report(log: TelemetryLog): void;
51
+ private enforceQueueLimit;
52
+ flush(): Promise<void>;
53
+ private handleSuccess;
54
+ private handleFailure;
55
+ shutdown(): Promise<void>;
56
+ }
57
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAExC,OAAO,KAAK,EAAyB,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE7E,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yDAAyD;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6CAA6C;IAC7C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,qBAAa,eAAe;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAErD,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,UAAU,CAAS;IAE3B,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,KAAK,CAA+C;IAG5D,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,YAAY,CAAS;IAG7B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAK;IAC7C,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAU;IAEnD,OAAO,CAAC,OAAO,CAAC,CAAyB;gBAE7B,IAAI,EAAE,sBAAsB;IA4BxC,MAAM,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAsC/B,OAAO,CAAC,iBAAiB;IAQnB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAuF5B,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,aAAa;IAkCf,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAUhC"}
@@ -0,0 +1,12 @@
1
+ import type { TelemetryClient } from "./client";
2
+ import type { JsonValue } from "./json";
3
+ export type InstrumentedFetchOptions = {
4
+ /** Base fetch to wrap (defaults to globalThis.fetch) */
5
+ baseFetch?: typeof fetch;
6
+ /** Additional metadata to include in all logs */
7
+ defaultMetadata?: Record<string, JsonValue>;
8
+ /** Source identifier (default: "openrouter") */
9
+ source?: string;
10
+ };
11
+ export declare function createInstrumentedFetch(client: TelemetryClient, options?: InstrumentedFetchOptions): typeof fetch;
12
+ //# sourceMappingURL=fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAKxC,MAAM,MAAM,wBAAwB,GAAG;IACrC,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iDAAiD;IACjD,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC5C,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAoBF,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,eAAe,EACvB,OAAO,CAAC,EAAE,wBAAwB,GACjC,OAAO,KAAK,CA8Td"}
@@ -0,0 +1,10 @@
1
+ export { TelemetryClient } from "./client";
2
+ export type { TelemetryClientOptions } from "./client";
3
+ export { createInstrumentedFetch } from "./fetch";
4
+ export type { InstrumentedFetchOptions } from "./fetch";
5
+ export { parseSSE } from "./sse";
6
+ export type { SSEEvent } from "./sse";
7
+ export type { TelemetryBatchPayload, TelemetryLog, TelemetryTrace, } from "./types/telemetry";
8
+ export { createId, safeJsonParse, sanitizeHeaders, toIso, truncateUtf8 } from "./json";
9
+ export type { JsonPrimitive, JsonValue } from "./json";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC3C,YAAY,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAGvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,SAAS,CAAC;AAClD,YAAY,EAAE,wBAAwB,EAAE,MAAM,SAAS,CAAC;AAGxD,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACjC,YAAY,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGtC,YAAY,EACV,qBAAqB,EACrB,YAAY,EACZ,cAAc,GACf,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,eAAe,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACvF,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,547 @@
1
+ // src/json.ts
2
+ function toIso(d) {
3
+ return d.toISOString();
4
+ }
5
+ function createId() {
6
+ const c = globalThis.crypto;
7
+ if (c?.randomUUID)
8
+ return c.randomUUID();
9
+ return `id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
10
+ }
11
+ function safeJsonParse(text) {
12
+ try {
13
+ const v = JSON.parse(text);
14
+ return { ok: true, value: v };
15
+ } catch (e) {
16
+ return { ok: false, error: e instanceof Error ? e.message : "Invalid JSON" };
17
+ }
18
+ }
19
+ var SENSITIVE_HEADERS = new Set([
20
+ "authorization",
21
+ "cookie",
22
+ "set-cookie",
23
+ "x-api-key",
24
+ "api-key"
25
+ ]);
26
+ function sanitizeHeaders(headers) {
27
+ const out = {};
28
+ if (!headers)
29
+ return out;
30
+ const set = (k, v) => {
31
+ const key = k.toLowerCase();
32
+ if (SENSITIVE_HEADERS.has(key))
33
+ return;
34
+ out[key] = v;
35
+ };
36
+ if (headers instanceof Headers) {
37
+ headers.forEach((v, k) => set(k, v));
38
+ } else if (Array.isArray(headers)) {
39
+ for (const [k, v] of headers)
40
+ set(k, v);
41
+ } else {
42
+ for (const [k, v] of Object.entries(headers))
43
+ set(k, String(v));
44
+ }
45
+ return out;
46
+ }
47
+ function truncateUtf8(input, maxBytes) {
48
+ const enc = new TextEncoder;
49
+ const bytes = enc.encode(input);
50
+ if (bytes.length <= maxBytes)
51
+ return { text: input, truncated: false };
52
+ const sliced = bytes.slice(0, maxBytes);
53
+ const dec = new TextDecoder("utf-8", { fatal: false });
54
+ return { text: dec.decode(sliced), truncated: true };
55
+ }
56
+
57
+ // src/client.ts
58
+ class TelemetryClient {
59
+ baseUrl;
60
+ apiKey;
61
+ project;
62
+ environment;
63
+ defaultMetadata;
64
+ timeoutMs;
65
+ maxBatchSize;
66
+ flushIntervalMs;
67
+ maxLogBytes;
68
+ maxQueueSize;
69
+ retryBaseDelayMs;
70
+ retryMaxDelayMs;
71
+ maxRetries;
72
+ queue = [];
73
+ flushing = false;
74
+ timer = null;
75
+ retryCount = 0;
76
+ retryDelayMs;
77
+ circuitOpen = false;
78
+ circuitOpenUntil = 0;
79
+ consecutiveFailures = 0;
80
+ circuitBreakerThreshold = 5;
81
+ circuitBreakerDurationMs = 30000;
82
+ onError;
83
+ constructor(opts) {
84
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
85
+ this.apiKey = opts.apiKey;
86
+ this.project = opts.project;
87
+ this.environment = opts.environment;
88
+ this.defaultMetadata = opts.defaultMetadata;
89
+ this.timeoutMs = opts.timeoutMs ?? 5000;
90
+ this.maxBatchSize = opts.maxBatchSize ?? 25;
91
+ this.flushIntervalMs = opts.flushIntervalMs ?? 2000;
92
+ this.maxLogBytes = opts.maxLogBytes ?? 1e6;
93
+ this.maxQueueSize = opts.maxQueueSize ?? 1000;
94
+ this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 1000;
95
+ this.retryMaxDelayMs = opts.retryMaxDelayMs ?? 30000;
96
+ this.maxRetries = opts.maxRetries ?? 5;
97
+ this.retryDelayMs = this.retryBaseDelayMs;
98
+ this.onError = opts.onError;
99
+ if (this.flushIntervalMs > 0) {
100
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
101
+ if (typeof this.timer === "object" && "unref" in this.timer) {
102
+ this.timer.unref();
103
+ }
104
+ }
105
+ }
106
+ report(log) {
107
+ if (this.project) {
108
+ log.project = log.project || this.project;
109
+ }
110
+ log.environment = log.environment ?? this.environment;
111
+ if (this.defaultMetadata) {
112
+ log.metadata = { ...this.defaultMetadata, ...log.metadata ?? {} };
113
+ }
114
+ const raw = JSON.stringify(log);
115
+ const truncated = truncateUtf8(raw, this.maxLogBytes);
116
+ if (!truncated.truncated) {
117
+ this.queue.push(log);
118
+ } else {
119
+ this.queue.push({
120
+ ...log,
121
+ request: {
122
+ ...log.request,
123
+ body_text: "[truncated - log exceeded maxLogBytes]",
124
+ body_truncated: true
125
+ },
126
+ error: log.error ?? { message: "Log truncated to maxLogBytes" }
127
+ });
128
+ }
129
+ this.enforceQueueLimit();
130
+ if (this.queue.length >= this.maxBatchSize) {
131
+ this.flush();
132
+ }
133
+ }
134
+ enforceQueueLimit() {
135
+ if (this.queue.length > this.maxQueueSize) {
136
+ const dropped = this.queue.length - this.maxQueueSize;
137
+ this.queue.splice(0, dropped);
138
+ this.onError?.(new Error(`Queue limit exceeded, dropped ${dropped} logs`));
139
+ }
140
+ }
141
+ async flush() {
142
+ if (this.flushing)
143
+ return;
144
+ if (this.queue.length === 0)
145
+ return;
146
+ if (this.circuitOpen) {
147
+ if (Date.now() < this.circuitOpenUntil) {
148
+ return;
149
+ }
150
+ this.circuitOpen = false;
151
+ }
152
+ this.flushing = true;
153
+ const batch = this.queue.splice(0, this.maxBatchSize);
154
+ try {
155
+ const payload = { logs: batch };
156
+ const ctrl = new AbortController;
157
+ const t = setTimeout(() => ctrl.abort(), this.timeoutMs);
158
+ const res = await fetch(`${this.baseUrl}/v1/logs/batch`, {
159
+ method: "POST",
160
+ headers: {
161
+ "content-type": "application/json",
162
+ authorization: `Bearer ${this.apiKey}`
163
+ },
164
+ body: JSON.stringify(payload),
165
+ signal: ctrl.signal
166
+ });
167
+ clearTimeout(t);
168
+ if (res.status === 207) {
169
+ const body = await res.json().catch(() => null);
170
+ const failedIds = new Set;
171
+ if (body && typeof body === "object") {
172
+ const failed = body.failed;
173
+ if (Array.isArray(failed)) {
174
+ for (const item of failed) {
175
+ if (item && typeof item === "object") {
176
+ const id = item.id;
177
+ if (typeof id === "string") {
178
+ failedIds.add(id);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ if (failedIds.size > 0) {
185
+ const retry = batch.filter((log) => failedIds.has(log.id));
186
+ if (retry.length > 0) {
187
+ this.queue.unshift(...retry);
188
+ this.enforceQueueLimit();
189
+ }
190
+ }
191
+ const partialMessage = body && typeof body === "object" ? body.partial_success?.error_message : undefined;
192
+ const message = typeof partialMessage === "string" ? partialMessage : "Telemetry partial success";
193
+ this.onError?.(new Error(message));
194
+ this.handleSuccess();
195
+ return;
196
+ }
197
+ if (!res.ok) {
198
+ const errorText = await res.text().catch(() => "");
199
+ throw new Error(`Telemetry ingest failed: ${res.status} ${res.statusText} - ${errorText}`);
200
+ }
201
+ this.handleSuccess();
202
+ } catch (err) {
203
+ this.handleFailure(batch, err);
204
+ } finally {
205
+ this.flushing = false;
206
+ }
207
+ }
208
+ handleSuccess() {
209
+ this.consecutiveFailures = 0;
210
+ this.retryCount = 0;
211
+ this.retryDelayMs = this.retryBaseDelayMs;
212
+ this.circuitOpen = false;
213
+ }
214
+ handleFailure(batch, err) {
215
+ this.consecutiveFailures++;
216
+ this.retryCount++;
217
+ if (this.consecutiveFailures >= this.circuitBreakerThreshold) {
218
+ this.circuitOpen = true;
219
+ this.circuitOpenUntil = Date.now() + this.circuitBreakerDurationMs;
220
+ this.onError?.(new Error(`Circuit breaker opened after ${this.consecutiveFailures} failures`));
221
+ }
222
+ if (this.retryCount > this.maxRetries) {
223
+ this.onError?.(new Error(`Dropping batch after ${this.maxRetries} retries: ${err}`));
224
+ this.retryCount = 0;
225
+ this.retryDelayMs = this.retryBaseDelayMs;
226
+ return;
227
+ }
228
+ this.queue.unshift(...batch);
229
+ this.enforceQueueLimit();
230
+ this.onError?.(err);
231
+ this.retryDelayMs = Math.min(this.retryDelayMs * 2 + Math.random() * 100, this.retryMaxDelayMs);
232
+ setTimeout(() => void this.flush(), this.retryDelayMs);
233
+ }
234
+ async shutdown() {
235
+ if (this.timer) {
236
+ clearInterval(this.timer);
237
+ this.timer = null;
238
+ }
239
+ this.circuitOpen = false;
240
+ await this.flush();
241
+ }
242
+ }
243
+ // src/sse.ts
244
+ function parseSSE(chunk) {
245
+ const events = [];
246
+ const normalized = chunk.replace(/\r\n/g, "\n");
247
+ const blocks = normalized.split("\n\n");
248
+ for (let i = 0;i < blocks.length - 1; i += 1) {
249
+ const block = blocks[i];
250
+ if (!block.trim())
251
+ continue;
252
+ const lines = block.split("\n");
253
+ const data = [];
254
+ let event;
255
+ let id;
256
+ for (const line of lines) {
257
+ if (!line || line.startsWith(":"))
258
+ continue;
259
+ const idx = line.indexOf(":");
260
+ if (idx === -1)
261
+ continue;
262
+ const field = line.slice(0, idx).trim();
263
+ const raw = line.slice(idx + 1);
264
+ const value = raw.startsWith(" ") ? raw.slice(1) : raw;
265
+ if (field === "data") {
266
+ data.push(value);
267
+ } else if (field === "event") {
268
+ event = value;
269
+ } else if (field === "id") {
270
+ id = value;
271
+ }
272
+ }
273
+ if (data.length === 0)
274
+ continue;
275
+ events.push({ data: data.join("\n"), event, id });
276
+ }
277
+ return events;
278
+ }
279
+
280
+ // src/fetch.ts
281
+ function shouldIntercept(url) {
282
+ return url.includes("/chat/completions") || url.includes("/completions");
283
+ }
284
+ function safeJsonParse2(text) {
285
+ try {
286
+ return { json: JSON.parse(text) };
287
+ } catch {
288
+ return { text };
289
+ }
290
+ }
291
+ function createInstrumentedFetch(client, options) {
292
+ const baseFetch = options?.baseFetch ?? globalThis.fetch;
293
+ const defaultMetadata = options?.defaultMetadata;
294
+ const source = options?.source ?? "openrouter";
295
+ const instrumentedFetch = async (input, init) => {
296
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
297
+ if (!shouldIntercept(url)) {
298
+ return baseFetch(input, init);
299
+ }
300
+ const startTime = new Date;
301
+ const logId = createId();
302
+ const method = init?.method ?? "GET";
303
+ const path = new URL(url).pathname;
304
+ let requestBody = {};
305
+ if (init?.body) {
306
+ const bodyStr = typeof init.body === "string" ? init.body : String(init.body);
307
+ requestBody = safeJsonParse2(bodyStr);
308
+ }
309
+ const model = requestBody.json?.model;
310
+ const streamed = requestBody.json?.stream === true;
311
+ const requestMetadata = requestBody.json?.metadata;
312
+ const sanitizedHeaders = sanitizeHeaders(init?.headers);
313
+ try {
314
+ const response = await baseFetch(input, init);
315
+ if (!streamed) {
316
+ const endTime = new Date;
317
+ const cloned = response.clone();
318
+ let responseBody = {};
319
+ let outputText;
320
+ let finishReason2;
321
+ let usage2;
322
+ try {
323
+ const text = await cloned.text();
324
+ responseBody = safeJsonParse2(text);
325
+ if (responseBody.json) {
326
+ const json = responseBody.json;
327
+ const choices = json.choices;
328
+ if (choices?.[0]) {
329
+ const message = choices[0].message;
330
+ outputText = message?.content;
331
+ finishReason2 = choices[0].finish_reason;
332
+ }
333
+ usage2 = json.usage;
334
+ }
335
+ } catch {
336
+ }
337
+ try {
338
+ const log = {
339
+ id: logId,
340
+ timestamp: toIso(startTime),
341
+ project: client.project ?? "",
342
+ environment: client.environment,
343
+ source,
344
+ method,
345
+ url,
346
+ path,
347
+ model,
348
+ streamed: false,
349
+ timing: {
350
+ started_at: toIso(startTime),
351
+ ended_at: toIso(endTime),
352
+ duration_ms: endTime.getTime() - startTime.getTime()
353
+ },
354
+ metadata: requestMetadata ? { ...defaultMetadata, ...requestMetadata } : defaultMetadata,
355
+ request: {
356
+ headers: sanitizedHeaders,
357
+ body_json: requestBody.json,
358
+ body_text: requestBody.text
359
+ },
360
+ response: {
361
+ status: response.status,
362
+ headers: sanitizeHeaders(response.headers),
363
+ body_json: responseBody.json,
364
+ body_text: responseBody.text
365
+ },
366
+ stream: outputText ? {
367
+ output_text: outputText,
368
+ finish_reason: finishReason2 ?? null,
369
+ usage: usage2
370
+ } : undefined,
371
+ usage: usage2
372
+ };
373
+ client.report(log);
374
+ } catch {
375
+ }
376
+ return response;
377
+ }
378
+ if (!response.body) {
379
+ return response;
380
+ }
381
+ const sseEvents = [];
382
+ let sseTruncated = false;
383
+ const textChunks = [];
384
+ const toolCalls = new Map;
385
+ let finishReason = null;
386
+ let usage;
387
+ const transform = new TransformStream({
388
+ transform(chunk, controller) {
389
+ controller.enqueue(chunk);
390
+ try {
391
+ const text = new TextDecoder().decode(chunk);
392
+ const events = parseSSE(text);
393
+ for (const event of events) {
394
+ if (event.data === "[DONE]")
395
+ continue;
396
+ try {
397
+ const parsed = JSON.parse(event.data);
398
+ if (sseEvents.length < 100) {
399
+ sseEvents.push(parsed);
400
+ } else if (!sseTruncated) {
401
+ sseTruncated = true;
402
+ }
403
+ const choices = parsed.choices;
404
+ const delta = choices?.[0]?.delta;
405
+ if (delta?.content) {
406
+ textChunks.push(delta.content);
407
+ }
408
+ const deltaToolCalls = delta?.tool_calls;
409
+ if (deltaToolCalls) {
410
+ for (const tc of deltaToolCalls) {
411
+ const idx = tc.index ?? 0;
412
+ if (!toolCalls.has(idx)) {
413
+ toolCalls.set(idx, {
414
+ id: tc.id ?? "",
415
+ type: "function",
416
+ function: { name: "", arguments: "" }
417
+ });
418
+ }
419
+ const existing = toolCalls.get(idx);
420
+ if (tc.id)
421
+ existing.id = tc.id;
422
+ const fn = tc.function;
423
+ if (fn?.name)
424
+ existing.function.name = fn.name;
425
+ if (fn?.arguments)
426
+ existing.function.arguments += fn.arguments;
427
+ }
428
+ }
429
+ if (choices?.[0]?.finish_reason) {
430
+ finishReason = choices[0].finish_reason;
431
+ }
432
+ if (parsed.usage) {
433
+ usage = parsed.usage;
434
+ }
435
+ } catch {
436
+ }
437
+ }
438
+ } catch {
439
+ }
440
+ },
441
+ flush() {
442
+ const endTime = new Date;
443
+ const toolCallsArray = Array.from(toolCalls.values());
444
+ const outputText = textChunks.join("");
445
+ let finalEvents = sseEvents;
446
+ if (sseTruncated && sseEvents.length >= 100) {
447
+ finalEvents = [
448
+ ...sseEvents.slice(0, 50),
449
+ { truncated: true, total: sseEvents.length },
450
+ ...sseEvents.slice(-50)
451
+ ];
452
+ }
453
+ try {
454
+ const log = {
455
+ id: logId,
456
+ timestamp: toIso(startTime),
457
+ project: client.project ?? "",
458
+ environment: client.environment,
459
+ source,
460
+ method,
461
+ url,
462
+ path,
463
+ model,
464
+ streamed: true,
465
+ timing: {
466
+ started_at: toIso(startTime),
467
+ ended_at: toIso(endTime),
468
+ duration_ms: endTime.getTime() - startTime.getTime()
469
+ },
470
+ metadata: requestMetadata ? { ...defaultMetadata, ...requestMetadata } : defaultMetadata,
471
+ request: {
472
+ headers: sanitizedHeaders,
473
+ body_json: requestBody.json,
474
+ body_text: requestBody.text
475
+ },
476
+ response: {
477
+ status: response.status,
478
+ headers: sanitizeHeaders(response.headers)
479
+ },
480
+ stream: {
481
+ sse_events: finalEvents,
482
+ sse_events_truncated: sseTruncated,
483
+ output_text: outputText,
484
+ tool_calls: toolCallsArray.length > 0 ? toolCallsArray : undefined,
485
+ finish_reason: finishReason,
486
+ usage
487
+ },
488
+ usage
489
+ };
490
+ client.report(log);
491
+ } catch {
492
+ }
493
+ }
494
+ });
495
+ return new Response(response.body.pipeThrough(transform), {
496
+ status: response.status,
497
+ statusText: response.statusText,
498
+ headers: response.headers
499
+ });
500
+ } catch (err) {
501
+ const endTime = new Date;
502
+ try {
503
+ const log = {
504
+ id: logId,
505
+ timestamp: toIso(startTime),
506
+ project: client.project ?? "",
507
+ environment: client.environment,
508
+ source,
509
+ method,
510
+ url,
511
+ path,
512
+ model,
513
+ streamed,
514
+ timing: {
515
+ started_at: toIso(startTime),
516
+ ended_at: toIso(endTime),
517
+ duration_ms: endTime.getTime() - startTime.getTime()
518
+ },
519
+ metadata: requestMetadata ? { ...defaultMetadata, ...requestMetadata } : defaultMetadata,
520
+ request: {
521
+ headers: sanitizedHeaders,
522
+ body_json: requestBody.json,
523
+ body_text: requestBody.text
524
+ },
525
+ error: {
526
+ message: err instanceof Error ? err.message : String(err),
527
+ stack: err instanceof Error ? err.stack : undefined
528
+ }
529
+ };
530
+ client.report(log);
531
+ } catch {
532
+ }
533
+ throw err;
534
+ }
535
+ };
536
+ return instrumentedFetch;
537
+ }
538
+ export {
539
+ truncateUtf8,
540
+ toIso,
541
+ sanitizeHeaders,
542
+ safeJsonParse,
543
+ parseSSE,
544
+ createInstrumentedFetch,
545
+ createId,
546
+ TelemetryClient
547
+ };
package/dist/json.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export declare function toIso(d: Date): string;
6
+ export declare function createId(): string;
7
+ export declare function safeJsonParse(text: string): {
8
+ ok: true;
9
+ value: JsonValue;
10
+ } | {
11
+ ok: false;
12
+ error: string;
13
+ };
14
+ export declare function sanitizeHeaders(headers: HeadersInit | undefined): Record<string, string>;
15
+ export declare function truncateUtf8(input: string, maxBytes: number): {
16
+ text: string;
17
+ truncated: boolean;
18
+ };
19
+ //# sourceMappingURL=json.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../src/json.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;AAC7D,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,SAAS,EAAE,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEnF,wBAAgB,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAErC;AAED,wBAAgB,QAAQ,IAAI,MAAM,CAIjC;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAOzG;AAUD,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBxF;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAQlG"}
package/dist/sse.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type SSEEvent = {
2
+ data: string;
3
+ event?: string;
4
+ id?: string;
5
+ };
6
+ export declare function parseSSE(chunk: string): SSEEvent[];
7
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAoClD"}
@@ -0,0 +1,56 @@
1
+ import type { JsonValue } from "../json";
2
+ export type TelemetryTrace = {
3
+ trace_id?: string;
4
+ span_id?: string;
5
+ parent_span_id?: string;
6
+ };
7
+ export type TelemetryLog = {
8
+ id: string;
9
+ timestamp: string;
10
+ project: string;
11
+ environment?: string;
12
+ source: string;
13
+ method: string;
14
+ url: string;
15
+ path: string;
16
+ model?: string;
17
+ generation_id?: string;
18
+ streamed: boolean;
19
+ timing: {
20
+ started_at: string;
21
+ ended_at?: string;
22
+ duration_ms?: number;
23
+ };
24
+ metadata?: Record<string, JsonValue>;
25
+ trace?: TelemetryTrace;
26
+ request: {
27
+ headers?: Record<string, string>;
28
+ body_json?: JsonValue;
29
+ body_text?: string;
30
+ body_truncated?: boolean;
31
+ };
32
+ response?: {
33
+ status: number;
34
+ headers?: Record<string, string>;
35
+ body_json?: JsonValue;
36
+ body_text?: string;
37
+ body_truncated?: boolean;
38
+ };
39
+ stream?: {
40
+ sse_events?: JsonValue[];
41
+ sse_events_truncated?: boolean;
42
+ output_text?: string;
43
+ tool_calls?: JsonValue;
44
+ finish_reason?: string | null;
45
+ usage?: JsonValue;
46
+ };
47
+ usage?: JsonValue;
48
+ error?: {
49
+ message: string;
50
+ stack?: string;
51
+ };
52
+ };
53
+ export type TelemetryBatchPayload = {
54
+ logs: TelemetryLog[];
55
+ };
56
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../../src/types/telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAElB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IAEb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAElB,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;IAEF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACrC,KAAK,CAAC,EAAE,cAAc,CAAC;IAEvB,OAAO,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,OAAO,CAAC;KAC1B,CAAC;IAEF,QAAQ,CAAC,EAAE;QACT,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QACjC,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,cAAc,CAAC,EAAE,OAAO,CAAC;KAC1B,CAAC;IAEF,MAAM,CAAC,EAAE;QACP,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;QACzB,oBAAoB,CAAC,EAAE,OAAO,CAAC;QAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,UAAU,CAAC,EAAE,SAAS,CAAC;QACvB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC9B,KAAK,CAAC,EAAE,SAAS,CAAC;KACnB,CAAC;IAEF,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@syntheticminds/wiretap-llm",
3
+ "version": "0.2.3",
4
+ "description": "Telemetry client for sending LLM logs to Wiretap",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "bun build ./src/index.ts --outdir ./dist --target node && tsc --emitDeclarationOnly",
21
+ "dev": "bun build ./src/index.ts --outdir ./dist --target node --watch",
22
+ "typecheck": "tsc --noEmit",
23
+ "test:e2e": "bun test tests/e2e --preload ./tests/e2e/setup.ts",
24
+ "test:e2e:setup": "bun run ./tests/e2e/setup.ts",
25
+ "test:e2e:teardown": "bun run ./tests/e2e/teardown.ts",
26
+ "prepublishOnly": "bun run build"
27
+ },
28
+ "keywords": [
29
+ "llm",
30
+ "telemetry",
31
+ "openrouter",
32
+ "openai",
33
+ "observability",
34
+ "monitoring",
35
+ "ai",
36
+ "wiretap"
37
+ ],
38
+ "author": "Saint",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/kingbootoshi/wiretap.git",
43
+ "directory": "packages/sdk"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/kingbootoshi/wiretap/issues"
47
+ },
48
+ "homepage": "https://github.com/kingbootoshi/wiretap/tree/main/packages/sdk#readme",
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ },
55
+ "devDependencies": {
56
+ "@supabase/supabase-js": "^2.90.1",
57
+ "openai": "latest",
58
+ "typescript": "^5.7.2"
59
+ }
60
+ }