wiretap-llm 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,18 +10,60 @@ npm install wiretap-llm
10
10
  bun add wiretap-llm
11
11
  ```
12
12
 
13
- ## Quick Start
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:
14
58
 
15
59
  ```ts
16
60
  import { TelemetryClient, createId, toIso } from "wiretap-llm";
17
61
 
18
- // Create client
19
62
  const client = new TelemetryClient({
20
63
  baseUrl: process.env.WIRETAP_BASE_URL!,
21
64
  apiKey: process.env.WIRETAP_API_KEY!,
22
65
  });
23
66
 
24
- // Send a log
25
67
  client.report({
26
68
  id: createId(),
27
69
  timestamp: toIso(new Date()),
@@ -31,7 +73,7 @@ client.report({
31
73
  url: "https://openrouter.ai/api/v1/chat/completions",
32
74
  path: "/v1/chat/completions",
33
75
  model: "anthropic/claude-sonnet-4",
34
- streamed: true,
76
+ streamed: false,
35
77
  timing: {
36
78
  started_at: toIso(startTime),
37
79
  ended_at: toIso(endTime),
@@ -40,14 +82,13 @@ client.report({
40
82
  request: {
41
83
  body_json: requestBody,
42
84
  },
43
- stream: {
44
- output_text: "Hello!",
45
- finish_reason: "stop",
46
- usage: { prompt_tokens: 10, completion_tokens: 5 },
85
+ response: {
86
+ status: 200,
87
+ body_json: responseBody,
47
88
  },
89
+ usage: responseBody.usage,
48
90
  });
49
91
 
50
- // On app shutdown
51
92
  await client.shutdown();
52
93
  ```
53
94
 
@@ -55,32 +96,55 @@ await client.shutdown();
55
96
 
56
97
  ### TelemetryClient Options
57
98
 
58
- | Option | Type | Required | Default | Description |
59
- |--------|------|----------|---------|-------------|
60
- | `baseUrl` | string | Yes | - | Your Wiretap instance URL |
61
- | `apiKey` | string | Yes | - | Your Wiretap API key |
62
- | `project` | string | No | - | Project slug (usually inferred from API key) |
63
- | `environment` | string | No | - | Environment name (e.g., "production") |
64
- | `defaultMetadata` | object | No | - | Metadata added to all logs |
65
- | `timeoutMs` | number | No | 5000 | Request timeout |
66
- | `maxBatchSize` | number | No | 25 | Max logs per batch |
67
- | `flushIntervalMs` | number | No | 2000 | Auto-flush interval |
68
- | `maxLogBytes` | number | No | 1000000 | Max log size before truncation |
69
- | `onError` | function | No | - | Error handler |
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
70
117
 
71
- ## API
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 |
72
123
 
73
- ### `client.report(log: TelemetryLog)`
124
+ ## Reliability Features
74
125
 
75
- Queue a log for sending. Logs are batched and sent automatically.
126
+ ### Automatic Retry with Exponential Backoff
76
127
 
77
- ### `client.flush()`
128
+ Failed batches are automatically retried with exponential backoff (1s, 2s, 4s, ...) up to 30s max delay.
78
129
 
79
- Manually flush pending logs.
130
+ ### Circuit Breaker
80
131
 
81
- ### `client.shutdown()`
132
+ After 5 consecutive failures, the circuit breaker opens for 30 seconds. During this time, flushes are skipped to prevent hammering a dead server.
82
133
 
83
- Flush remaining logs and stop the client. Call on app shutdown.
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
+ ```
84
148
 
85
149
  ## Helper Utilities
86
150
 
package/dist/client.d.ts CHANGED
@@ -11,6 +11,14 @@ export type TelemetryClientOptions = {
11
11
  maxBatchSize?: number;
12
12
  flushIntervalMs?: number;
13
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;
14
22
  onError?: (err: unknown) => void;
15
23
  };
16
24
  export declare class TelemetryClient {
@@ -23,13 +31,27 @@ export declare class TelemetryClient {
23
31
  private maxBatchSize;
24
32
  private flushIntervalMs;
25
33
  private maxLogBytes;
34
+ private maxQueueSize;
35
+ private retryBaseDelayMs;
36
+ private retryMaxDelayMs;
37
+ private maxRetries;
26
38
  private queue;
27
39
  private flushing;
28
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;
29
48
  private onError?;
30
49
  constructor(opts: TelemetryClientOptions);
31
50
  report(log: TelemetryLog): void;
51
+ private enforceQueueLimit;
32
52
  flush(): Promise<void>;
53
+ private handleSuccess;
54
+ private handleFailure;
33
55
  shutdown(): Promise<void>;
34
56
  }
35
57
  //# sourceMappingURL=client.d.ts.map
@@ -1 +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,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;IAE5B,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,KAAK,CAA+C;IAE5D,OAAO,CAAC,OAAO,CAAC,CAAyB;gBAE7B,IAAI,EAAE,sBAAsB;IAuBxC,MAAM,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAmCzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0EtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAQhC"}
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,CAsTd"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { TelemetryClient } from "./client";
2
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";
3
7
  export type { TelemetryBatchPayload, TelemetryLog, TelemetryTrace, } from "./types/telemetry";
4
8
  export { createId, safeJsonParse, sanitizeHeaders, toIso, truncateUtf8 } from "./json";
5
9
  export type { JsonPrimitive, JsonValue } from "./json";
@@ -1 +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,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"}
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 CHANGED
@@ -65,9 +65,20 @@ class TelemetryClient {
65
65
  maxBatchSize;
66
66
  flushIntervalMs;
67
67
  maxLogBytes;
68
+ maxQueueSize;
69
+ retryBaseDelayMs;
70
+ retryMaxDelayMs;
71
+ maxRetries;
68
72
  queue = [];
69
73
  flushing = false;
70
74
  timer = null;
75
+ retryCount = 0;
76
+ retryDelayMs;
77
+ circuitOpen = false;
78
+ circuitOpenUntil = 0;
79
+ consecutiveFailures = 0;
80
+ circuitBreakerThreshold = 5;
81
+ circuitBreakerDurationMs = 30000;
71
82
  onError;
72
83
  constructor(opts) {
73
84
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -79,6 +90,11 @@ class TelemetryClient {
79
90
  this.maxBatchSize = opts.maxBatchSize ?? 25;
80
91
  this.flushIntervalMs = opts.flushIntervalMs ?? 2000;
81
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;
82
98
  this.onError = opts.onError;
83
99
  if (this.flushIntervalMs > 0) {
84
100
  this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
@@ -110,15 +126,29 @@ class TelemetryClient {
110
126
  error: log.error ?? { message: "Log truncated to maxLogBytes" }
111
127
  });
112
128
  }
129
+ this.enforceQueueLimit();
113
130
  if (this.queue.length >= this.maxBatchSize) {
114
131
  this.flush();
115
132
  }
116
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
+ }
117
141
  async flush() {
118
142
  if (this.flushing)
119
143
  return;
120
144
  if (this.queue.length === 0)
121
145
  return;
146
+ if (this.circuitOpen) {
147
+ if (Date.now() < this.circuitOpenUntil) {
148
+ return;
149
+ }
150
+ this.circuitOpen = false;
151
+ }
122
152
  this.flushing = true;
123
153
  const batch = this.queue.splice(0, this.maxBatchSize);
124
154
  try {
@@ -155,38 +185,362 @@ class TelemetryClient {
155
185
  const retry = batch.filter((log) => failedIds.has(log.id));
156
186
  if (retry.length > 0) {
157
187
  this.queue.unshift(...retry);
188
+ this.enforceQueueLimit();
158
189
  }
159
190
  }
160
191
  const partialMessage = body && typeof body === "object" ? body.partial_success?.error_message : undefined;
161
192
  const message = typeof partialMessage === "string" ? partialMessage : "Telemetry partial success";
162
193
  this.onError?.(new Error(message));
194
+ this.handleSuccess();
163
195
  return;
164
196
  }
165
197
  if (!res.ok) {
166
- this.queue.unshift(...batch);
167
198
  const errorText = await res.text().catch(() => "");
168
199
  throw new Error(`Telemetry ingest failed: ${res.status} ${res.statusText} - ${errorText}`);
169
200
  }
201
+ this.handleSuccess();
170
202
  } catch (err) {
171
- this.queue.unshift(...batch);
172
- this.onError?.(err);
203
+ this.handleFailure(batch, err);
173
204
  } finally {
174
205
  this.flushing = false;
175
206
  }
176
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
+ }
177
234
  async shutdown() {
178
235
  if (this.timer) {
179
236
  clearInterval(this.timer);
180
237
  this.timer = null;
181
238
  }
239
+ this.circuitOpen = false;
182
240
  await this.flush();
183
241
  }
184
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 sanitizedHeaders = sanitizeHeaders(init?.headers);
312
+ try {
313
+ const response = await baseFetch(input, init);
314
+ if (!streamed) {
315
+ const endTime = new Date;
316
+ const cloned = response.clone();
317
+ let responseBody = {};
318
+ let outputText;
319
+ let finishReason2;
320
+ let usage2;
321
+ try {
322
+ const text = await cloned.text();
323
+ responseBody = safeJsonParse2(text);
324
+ if (responseBody.json) {
325
+ const json = responseBody.json;
326
+ const choices = json.choices;
327
+ if (choices?.[0]) {
328
+ const message = choices[0].message;
329
+ outputText = message?.content;
330
+ finishReason2 = choices[0].finish_reason;
331
+ }
332
+ usage2 = json.usage;
333
+ }
334
+ } catch {
335
+ }
336
+ try {
337
+ const log = {
338
+ id: logId,
339
+ timestamp: toIso(startTime),
340
+ project: client.project ?? "",
341
+ environment: client.environment,
342
+ source,
343
+ method,
344
+ url,
345
+ path,
346
+ model,
347
+ streamed: false,
348
+ timing: {
349
+ started_at: toIso(startTime),
350
+ ended_at: toIso(endTime),
351
+ duration_ms: endTime.getTime() - startTime.getTime()
352
+ },
353
+ metadata: defaultMetadata,
354
+ request: {
355
+ headers: sanitizedHeaders,
356
+ body_json: requestBody.json,
357
+ body_text: requestBody.text
358
+ },
359
+ response: {
360
+ status: response.status,
361
+ headers: sanitizeHeaders(response.headers),
362
+ body_json: responseBody.json,
363
+ body_text: responseBody.text
364
+ },
365
+ stream: outputText ? {
366
+ output_text: outputText,
367
+ finish_reason: finishReason2 ?? null,
368
+ usage: usage2
369
+ } : undefined,
370
+ usage: usage2
371
+ };
372
+ client.report(log);
373
+ } catch {
374
+ }
375
+ return response;
376
+ }
377
+ if (!response.body) {
378
+ return response;
379
+ }
380
+ const sseEvents = [];
381
+ let sseTruncated = false;
382
+ const textChunks = [];
383
+ const toolCalls = new Map;
384
+ let finishReason = null;
385
+ let usage;
386
+ const transform = new TransformStream({
387
+ transform(chunk, controller) {
388
+ controller.enqueue(chunk);
389
+ try {
390
+ const text = new TextDecoder().decode(chunk);
391
+ const events = parseSSE(text);
392
+ for (const event of events) {
393
+ if (event.data === "[DONE]")
394
+ continue;
395
+ try {
396
+ const parsed = JSON.parse(event.data);
397
+ if (sseEvents.length < 100) {
398
+ sseEvents.push(parsed);
399
+ } else if (!sseTruncated) {
400
+ sseTruncated = true;
401
+ }
402
+ const choices = parsed.choices;
403
+ const delta = choices?.[0]?.delta;
404
+ if (delta?.content) {
405
+ textChunks.push(delta.content);
406
+ }
407
+ const deltaToolCalls = delta?.tool_calls;
408
+ if (deltaToolCalls) {
409
+ for (const tc of deltaToolCalls) {
410
+ const idx = tc.index ?? 0;
411
+ if (!toolCalls.has(idx)) {
412
+ toolCalls.set(idx, {
413
+ id: tc.id ?? "",
414
+ type: "function",
415
+ function: { name: "", arguments: "" }
416
+ });
417
+ }
418
+ const existing = toolCalls.get(idx);
419
+ if (tc.id)
420
+ existing.id = tc.id;
421
+ const fn = tc.function;
422
+ if (fn?.name)
423
+ existing.function.name = fn.name;
424
+ if (fn?.arguments)
425
+ existing.function.arguments += fn.arguments;
426
+ }
427
+ }
428
+ if (choices?.[0]?.finish_reason) {
429
+ finishReason = choices[0].finish_reason;
430
+ }
431
+ if (parsed.usage) {
432
+ usage = parsed.usage;
433
+ }
434
+ } catch {
435
+ }
436
+ }
437
+ } catch {
438
+ }
439
+ },
440
+ flush() {
441
+ const endTime = new Date;
442
+ const toolCallsArray = Array.from(toolCalls.values());
443
+ const outputText = textChunks.join("");
444
+ let finalEvents = sseEvents;
445
+ if (sseTruncated && sseEvents.length >= 100) {
446
+ finalEvents = [
447
+ ...sseEvents.slice(0, 50),
448
+ { truncated: true, total: sseEvents.length },
449
+ ...sseEvents.slice(-50)
450
+ ];
451
+ }
452
+ try {
453
+ const log = {
454
+ id: logId,
455
+ timestamp: toIso(startTime),
456
+ project: client.project ?? "",
457
+ environment: client.environment,
458
+ source,
459
+ method,
460
+ url,
461
+ path,
462
+ model,
463
+ streamed: true,
464
+ timing: {
465
+ started_at: toIso(startTime),
466
+ ended_at: toIso(endTime),
467
+ duration_ms: endTime.getTime() - startTime.getTime()
468
+ },
469
+ metadata: defaultMetadata,
470
+ request: {
471
+ headers: sanitizedHeaders,
472
+ body_json: requestBody.json,
473
+ body_text: requestBody.text
474
+ },
475
+ response: {
476
+ status: response.status,
477
+ headers: sanitizeHeaders(response.headers)
478
+ },
479
+ stream: {
480
+ sse_events: finalEvents,
481
+ sse_events_truncated: sseTruncated,
482
+ output_text: outputText,
483
+ tool_calls: toolCallsArray.length > 0 ? toolCallsArray : undefined,
484
+ finish_reason: finishReason,
485
+ usage
486
+ },
487
+ usage
488
+ };
489
+ client.report(log);
490
+ } catch {
491
+ }
492
+ }
493
+ });
494
+ return new Response(response.body.pipeThrough(transform), {
495
+ status: response.status,
496
+ statusText: response.statusText,
497
+ headers: response.headers
498
+ });
499
+ } catch (err) {
500
+ const endTime = new Date;
501
+ try {
502
+ const log = {
503
+ id: logId,
504
+ timestamp: toIso(startTime),
505
+ project: client.project ?? "",
506
+ environment: client.environment,
507
+ source,
508
+ method,
509
+ url,
510
+ path,
511
+ model,
512
+ streamed,
513
+ timing: {
514
+ started_at: toIso(startTime),
515
+ ended_at: toIso(endTime),
516
+ duration_ms: endTime.getTime() - startTime.getTime()
517
+ },
518
+ metadata: defaultMetadata,
519
+ request: {
520
+ headers: sanitizedHeaders,
521
+ body_json: requestBody.json,
522
+ body_text: requestBody.text
523
+ },
524
+ error: {
525
+ message: err instanceof Error ? err.message : String(err),
526
+ stack: err instanceof Error ? err.stack : undefined
527
+ }
528
+ };
529
+ client.report(log);
530
+ } catch {
531
+ }
532
+ throw err;
533
+ }
534
+ };
535
+ return instrumentedFetch;
536
+ }
185
537
  export {
186
538
  truncateUtf8,
187
539
  toIso,
188
540
  sanitizeHeaders,
189
541
  safeJsonParse,
542
+ parseSSE,
543
+ createInstrumentedFetch,
190
544
  createId,
191
545
  TelemetryClient
192
546
  };
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiretap-llm",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Telemetry client for sending LLM logs to Wiretap",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",