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 +92 -28
- package/dist/client.d.ts +22 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/fetch.d.ts +12 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +357 -3
- package/dist/sse.d.ts +7 -0
- package/dist/sse.d.ts.map +1 -0
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 |
|
|
59
|
-
|
|
60
|
-
| `baseUrl` | string |
|
|
61
|
-
| `apiKey` | string |
|
|
62
|
-
| `project` | string |
|
|
63
|
-
| `environment` | string |
|
|
64
|
-
| `defaultMetadata` | object |
|
|
65
|
-
| `timeoutMs` | number |
|
|
66
|
-
| `maxBatchSize` | number |
|
|
67
|
-
| `flushIntervalMs` | number |
|
|
68
|
-
| `maxLogBytes` | number |
|
|
69
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
## Reliability Features
|
|
74
125
|
|
|
75
|
-
|
|
126
|
+
### Automatic Retry with Exponential Backoff
|
|
76
127
|
|
|
77
|
-
|
|
128
|
+
Failed batches are automatically retried with exponential backoff (1s, 2s, 4s, ...) up to 30s max delay.
|
|
78
129
|
|
|
79
|
-
|
|
130
|
+
### Circuit Breaker
|
|
80
131
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
package/dist/client.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/fetch.d.ts
ADDED
|
@@ -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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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 @@
|
|
|
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"}
|