weflayr 0.1.2 → 0.3.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/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ WEFLAYR_INTAKE_URL=https://api.weflayr.com
2
+ WEFLAYR_CLIENT_ID=your-client-id-uuid
3
+ WEFLAYR_CLIENT_SECRET=your-client-secret
package/README.md CHANGED
@@ -1,147 +1,92 @@
1
- # Weflayr JS SDK
1
+ # Weflayr Node.js SDK
2
2
 
3
- Drop-in instrumented wrapper for the OpenAI Node.js SDK add telemetry to your LLM calls in two lines.
3
+ Observability for Node.js - instrument any client or method with one line.
4
4
 
5
- ```
6
- your code → instrument(openai) → OpenAI SDK → OpenAI API
7
-
8
- Weflayr Intake API
9
- (before · after · error events, fire-and-forget)
10
- ```
11
-
12
- ---
13
-
14
- ## Installation
5
+ ## Install
15
6
 
16
7
  ```bash
17
8
  npm install weflayr
18
9
  ```
19
10
 
20
- Requires Node.js 18+ and `openai>=4.0.0`.
21
-
22
- ---
23
-
24
- ## Implementation steps
25
-
26
- ### 1. Get your credentials
27
-
28
- Sign in at [weflayr.com](https://weflayr.com), create a **Flayr**, and copy your `client_id` and `client_secret`.
29
-
30
- ### 2. Set environment variables
11
+ ## Environment variables
31
12
 
32
13
  ```bash
33
14
  WEFLAYR_INTAKE_URL=https://api.weflayr.com
34
- WEFLAYR_CLIENT_ID=<your-client-id>
35
- WEFLAYR_CLIENT_SECRET=<your-client-secret>
36
- ```
37
-
38
- Or add them to a `.env` file — the SDK uses `dotenv` automatically.
39
-
40
- ### 3. Initialize Weflayr at startup
41
-
42
- Call `setupWeflayr()` once, before any LLM calls. This registers the OpenTelemetry trace provider that powers telemetry.
43
-
44
- ```js
45
- import { setupWeflayr } from "weflayr/openai";
46
-
47
- await setupWeflayr();
48
- ```
49
-
50
- ### 4. Instrument your OpenAI client
51
-
52
- Wrap your existing client with `instrument()`. The returned client is a drop-in replacement.
53
-
54
- ```js
55
- import OpenAI from "openai";
56
- import { instrument } from "weflayr/openai";
57
-
58
- const openai = instrument(new OpenAI());
15
+ WEFLAYR_CLIENT_ID=your-client-id-uuid
16
+ WEFLAYR_CLIENT_SECRET=your-client-secret
59
17
  ```
60
18
 
61
- ### 5. Use it normally
62
-
63
- No other changes needed.
19
+ ## Usage
64
20
 
65
21
  ```js
66
- const response = await openai.chat.completions.create({
67
- model: "gpt-4o-mini",
68
- messages: [{ role: "user", content: "Hello!" }],
22
+ const { weflayr_setup, weflayr_instrument } = require('weflayr');
23
+
24
+ weflayr_setup({
25
+ event_mode: 'default', // 'default' | 'light' (light skips before events)
26
+ content_policy: 'ignore', // 'ignore' removes ignore_fields | 'allow' keeps only allow_fields
27
+ ignore_fields: ['messages[].content', 'choices[].message.content'],
28
+ allow_fields: [],
29
+ methods: [
30
+ { call: 'chat.completions.create' }
31
+ ],
32
+ }, { enabled: true });
33
+
34
+ const OpenAI = require('openai');
35
+ const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
36
+
37
+ // Tags are stripped from args before the API call and attached to the event
38
+ const response = await client.chat.completions.create({
39
+ model: 'gpt-4o-mini',
40
+ messages: [{ role: 'user', content: 'Hello' }],
41
+ __weflayr_tags: { feature: 'chat', customer_id: '42' },
69
42
  });
70
-
71
- console.log(response.choices[0].message.content);
72
43
  ```
73
44
 
74
- ### 6. (Optional) Add tags
75
-
76
- Pass a `tags` object in any call params to attach metadata — useful for slicing analytics by feature, user, or environment.
45
+ ## Instrumenting a plain function
77
46
 
78
47
  ```js
79
- const response = await openai.chat.completions.create({
80
- model: "gpt-4o-mini",
81
- messages: [{ role: "user", content: "Summarize this." }],
82
- tags: { feature: "summarization", userId: "u_123", env: "production" },
83
- });
48
+ async function fetchModels() {
49
+ const res = await fetch('https://api.example.com/models');
50
+ return res.json();
51
+ }
52
+
53
+ weflayr_setup({
54
+ event_mode: 'default',
55
+ content_policy: 'ignore',
56
+ ignore_fields: ['data'],
57
+ methods: [
58
+ {
59
+ call: 'fetchModels',
60
+ middleware: (_args, response) => ({ count: response?.data?.length ?? 0 }),
61
+ },
62
+ ],
63
+ }, { enabled: true });
64
+
65
+ const instrumented = weflayr_instrument(fetchModels);
66
+ const models = await instrumented({ __weflayr_tags: { app: 'my-app' } });
84
67
  ```
85
68
 
86
- Tags are stripped before the request reaches OpenAI — they never affect the API call.
87
-
88
- ---
89
-
90
- ## Covered endpoints
91
-
92
- All token-consuming OpenAI endpoints are instrumented with precise extractors. Any endpoint not in the list below is wrapped automatically by a fallback proxy.
93
-
94
- | Endpoint | Fields tracked |
95
- |---|---|
96
- | `chat.completions.create` | `model`, `message_count`, `prompt_tokens`, `completion_tokens` |
97
- | `completions.create` (legacy) | `model`, `prompt_length`, `prompt_tokens`, `completion_tokens` |
98
- | `embeddings.create` | `model`, `input_count`, `prompt_tokens`, `total_tokens` |
99
- | `responses.create` | `model`, `input_count`, `input_tokens`, `output_tokens`, `cached_tokens` |
100
- | `audio.speech.create` (TTS) | `model`, `voice`, `char_count` |
101
- | `audio.transcriptions.create` (STT) | `model`, `language`, token/duration usage |
102
- | `audio.translations.create` | `model`, token/duration usage |
103
-
104
- Any other method on the client is wrapped by a fallback proxy and tracked under its dotted path (e.g. `images.generate`).
105
-
106
- ---
69
+ ## Settings reference
107
70
 
108
- ## Telemetry events
109
-
110
- Each call emits up to two events to your intake API:
111
-
112
- | Event | When | Key fields |
71
+ | Field | Type | Description |
113
72
  |---|---|---|
114
- | `<endpoint>.before` | Before the call | `model`, call-specific params, `tags` |
115
- | `<endpoint>.after` | On success | all `.before` fields + `elapsed_ms` + token usage |
116
- | `<endpoint>.error` | On failure | all `.before` fields + `elapsed_ms` + `error_type`, `error_message` |
117
-
118
- Events are sent **fire-and-forget** they never block your code or throw.
73
+ | `event_mode` | `'default'` \| `'light'` | `light` skips before events |
74
+ | `content_policy` | `'ignore'` \| `'allow'` | Which filtering mode to apply |
75
+ | `ignore_fields` | `string[]` | Fields removed from payloads (used when `content_policy: 'ignore'`) |
76
+ | `allow_fields` | `string[]` | Fields kept in payloads; everything else removed (used when `content_policy: 'allow'`) |
77
+ | `methods` | `{ call, middleware? }[]` | Whitelisted method paths to instrument |
119
78
 
120
- By default, message content and prompt text are stripped before sending. Fields omitted: `messages`, `prompt`, `response_content`.
79
+ Field paths support dot notation (`foo.bar`), and array traversal (`messages[].content`).
121
80
 
122
- ---
81
+ ## Middleware
123
82
 
124
- ## Full example
83
+ Middleware runs on both **before** and **after** events. `response` is `null` for before events.
125
84
 
126
85
  ```js
127
- import OpenAI from "openai";
128
- import { setupWeflayr, instrument } from "weflayr/openai";
129
-
130
- await setupWeflayr();
131
-
132
- const openai = instrument(new OpenAI());
133
-
134
- const response = await openai.chat.completions.create({
135
- model: "gpt-4o-mini",
136
- messages: [{ role: "user", content: "What is 2 + 2?" }],
137
- tags: { feature: "math", env: "production" },
138
- });
139
-
140
- console.log(response.choices[0].message.content);
86
+ middleware: (args, response) => ({
87
+ char_count: args?.input?.length ?? 0,
88
+ result_count: response?.data?.length ?? 0,
89
+ })
141
90
  ```
142
91
 
143
- ---
144
-
145
- ## License
146
-
147
- [Elastic License 2.0](LICENSE) — free to use, modifications and redistribution not permitted.
92
+ The returned object is merged into the event payload.
package/package.json CHANGED
@@ -1,57 +1,18 @@
1
1
  {
2
2
  "name": "weflayr",
3
- "version": "0.1.2",
4
- "description": "Drop-in instrumented wrappers for AI clients with zero-overhead telemetry",
5
- "type": "module",
6
- "exports": {
7
- ".": "./src/weflayr.js",
8
- "./openai": "./src/openai.js"
9
- },
10
- "files": [
11
- "src/"
12
- ],
3
+ "version": "0.3.0",
4
+ "description": "Weflayr Node.js SDK instrument any LLM client via JS Proxy",
5
+ "main": "src/index.js",
13
6
  "scripts": {
14
- "test": "node --test tests/weflayr.test.js tests/openai.test.js",
15
- "prepublishOnly": "node --input-type=module --eval \"import './src/weflayr.js'\" 2>/dev/null; echo 'pre-publish check passed'"
7
+ "test": "node --test tests/index.test.js"
16
8
  },
17
- "keywords": [
18
- "llm",
19
- "telemetry",
20
- "observability",
21
- "openai",
22
- "instrumentation",
23
- "tracing",
24
- "ai",
25
- "otel",
26
- "opentelemetry"
27
- ],
9
+ "keywords": ["llm", "observability", "weflayr"],
28
10
  "author": "Weflayr <contact@weflayr.com>",
29
11
  "license": "Elastic-2.0",
30
- "homepage": "https://weflayr.com",
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/WeFlayr/public-mirror-js-sdk.git"
34
- },
35
- "bugs": {
36
- "url": "https://github.com/WeFlayr/public-mirror-js-sdk/issues"
37
- },
38
12
  "engines": {
39
13
  "node": ">=18.0.0"
40
14
  },
41
15
  "dependencies": {
42
- "dotenv": "^16.0.0",
43
- "@opentelemetry/api": "^1.9.0",
44
- "@opentelemetry/sdk-trace-base": "^1.30.0"
45
- },
46
- "optionalDependencies": {
47
- "@opentelemetry/exporter-trace-otlp-http": "^0.57.0"
48
- },
49
- "peerDependencies": {
50
- "openai": ">=4.0.0"
51
- },
52
- "peerDependenciesMeta": {
53
- "openai": {
54
- "optional": true
55
- }
16
+ "dotenv": "^16.0.0"
56
17
  }
57
18
  }
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ require('dotenv').config();
4
+
5
+ const { configure, weflayr_instrument, send_event } = require('./instrument');
6
+
7
+ /**
8
+ *
9
+ * @param {*} settings: settings for weflayr
10
+ * @param {*} on/off: easily enable or disable weflayr
11
+ * @returns
12
+ */
13
+ function weflayr_setup(settings, { enabled = true } = {}) {
14
+ if (!enabled) return;
15
+
16
+ const intakeUrl = process.env.WEFLAYR_INTAKE_URL;
17
+ const clientId = process.env.WEFLAYR_CLIENT_ID;
18
+ const clientSecret = process.env.WEFLAYR_CLIENT_SECRET;
19
+
20
+ if (!intakeUrl || !clientId || !clientSecret) {
21
+ throw new Error(
22
+ 'Weflayr: WEFLAYR_INTAKE_URL, WEFLAYR_CLIENT_ID and WEFLAYR_CLIENT_SECRET must be set'
23
+ );
24
+ }
25
+
26
+ configure(intakeUrl, clientId, clientSecret, settings);
27
+ }
28
+
29
+ module.exports = { weflayr_setup, weflayr_instrument, send_event };
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { randomUUID } = require('crypto');
6
+
7
+ let _config = null;
8
+
9
+ function configure(intakeUrl, clientId, clientSecret, settings) {
10
+ _config = { intakeUrl, clientId, clientSecret, settings };
11
+ }
12
+
13
+ function weflayr_instrument(target) {
14
+ if (!_config) return target;
15
+
16
+ if (typeof target === 'function') {
17
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === target.name);
18
+ if (!methodConfig) return target;
19
+ return _wrapFn(target, target.name, methodConfig);
20
+ }
21
+
22
+ if (target && typeof target === 'object') {
23
+ return _makeProxy(target, '');
24
+ }
25
+
26
+ return target;
27
+ }
28
+
29
+ function _makeProxy(target, pathPrefix) {
30
+ return new Proxy(target, {
31
+ get(obj, prop) {
32
+ if (typeof prop === 'symbol') return Reflect.get(obj, prop);
33
+
34
+ const value = Reflect.get(obj, prop);
35
+ const fullPath = pathPrefix ? `${pathPrefix}.${prop}` : prop;
36
+
37
+ if (typeof value === 'function') {
38
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === fullPath);
39
+ if (methodConfig) {
40
+ return _wrapFn(value.bind(obj), fullPath, methodConfig);
41
+ }
42
+ return value.bind(obj);
43
+ }
44
+
45
+ if (value && typeof value === 'object') {
46
+ return _makeProxy(value, fullPath);
47
+ }
48
+
49
+ return value;
50
+ },
51
+ });
52
+ }
53
+
54
+ function _wrapFn(fn, methodName, methodConfig) {
55
+ return async function (...args) {
56
+ const { tags, cleanArgs } = _extractTags(args);
57
+ const startTime = Date.now();
58
+ const { settings } = _config;
59
+ const eventId = randomUUID();
60
+ // For multi-arg methods (e.g. convert(voiceId, request)), use the last arg as the
61
+ // request object so middleware and content policy see the options, not the id.
62
+ const requestArgs = cleanArgs[cleanArgs.length - 1];
63
+
64
+ if (settings.event_mode !== 'light') {
65
+ const beforeMiddlewareData = methodConfig.middleware
66
+ ? methodConfig.middleware(requestArgs, null) || {}
67
+ : {};
68
+ _sendEvent('before', {
69
+ event_id: eventId,
70
+ method: methodName,
71
+ tags,
72
+ args: _applyContentPolicy(requestArgs, settings),
73
+ ...beforeMiddlewareData,
74
+ });
75
+ }
76
+
77
+ try {
78
+ const result = await fn(...cleanArgs);
79
+
80
+ if (_isAsyncIterable(result)) {
81
+ _sendEvent('stream_start', { event_id: eventId, method: methodName, tags });
82
+ return _wrapStream(result, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId);
83
+ }
84
+
85
+ const middlewareData = methodConfig.middleware
86
+ ? methodConfig.middleware(requestArgs, result) || {}
87
+ : {};
88
+
89
+ _sendEvent('after', {
90
+ event_id: eventId,
91
+ method: methodName,
92
+ tags,
93
+ args: _applyContentPolicy(requestArgs, settings),
94
+ response: _applyContentPolicy(_toPlain(result), settings),
95
+ elapsed_ms: Date.now() - startTime,
96
+ ...middlewareData,
97
+ });
98
+
99
+ return result;
100
+ } catch (err) {
101
+ await _sendEvent('after', {
102
+ event_id: eventId,
103
+ method: methodName,
104
+ tags,
105
+ args: _applyContentPolicy(requestArgs, settings),
106
+ error: err.message ?? 'unknown',
107
+ status_code: err.status ?? err.statusCode ?? 'unknown',
108
+ elapsed_ms: Date.now() - startTime,
109
+ });
110
+ throw err;
111
+ }
112
+ };
113
+ }
114
+
115
+ async function* _wrapStream(stream, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId) {
116
+ let lastChunk = null;
117
+ const accumulator = methodConfig.streamMiddleware ? methodConfig.streamMiddleware() : null;
118
+
119
+ try {
120
+ for await (const chunk of stream) {
121
+ lastChunk = chunk;
122
+ if (accumulator && accumulator.onChunk(chunk)) {
123
+ _sendEvent('stream_pending', {
124
+ event_id: eventId,
125
+ method: methodName,
126
+ tags,
127
+ elapsed_ms: Date.now() - startTime,
128
+ ...accumulator.finalize(),
129
+ });
130
+ }
131
+ yield chunk;
132
+ }
133
+
134
+ const middlewareData = accumulator
135
+ ? accumulator.finalize()
136
+ : (methodConfig.middleware && lastChunk ? methodConfig.middleware(requestArgs, lastChunk) || {}
137
+ : {});
138
+
139
+ _sendEvent('after', {
140
+ event_id: eventId,
141
+ method: methodName,
142
+ tags,
143
+ args: _applyContentPolicy(requestArgs, settings),
144
+ response: _applyContentPolicy(_toPlain(lastChunk), settings),
145
+ elapsed_ms: Date.now() - startTime,
146
+ ...middlewareData,
147
+ });
148
+ } catch (err) {
149
+ await _sendEvent('after', {
150
+ event_id: eventId,
151
+ method: methodName,
152
+ tags,
153
+ args: _applyContentPolicy(requestArgs, settings),
154
+ error: err.message ?? 'unknown',
155
+ status_code: err.status ?? err.statusCode ?? 'unknown',
156
+ elapsed_ms: Date.now() - startTime,
157
+ });
158
+ throw err;
159
+ }
160
+ }
161
+
162
+ function _extractTags(args) {
163
+ let tags = {};
164
+ const cleanArgs = args.map(arg => {
165
+ if (arg && typeof arg === 'object' && !Array.isArray(arg) && '__weflayr_tags' in arg) {
166
+ const { __weflayr_tags, ...rest } = arg;
167
+ tags = __weflayr_tags || {};
168
+ return rest;
169
+ }
170
+ return arg;
171
+ });
172
+ return { tags, cleanArgs };
173
+ }
174
+
175
+ function _isAsyncIterable(value) {
176
+ return value != null && typeof value[Symbol.asyncIterator] === 'function';
177
+ }
178
+
179
+ function _toPlain(value) {
180
+ if (!value) return value;
181
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof ArrayBuffer) return null;
182
+ if (typeof value.toJSON === 'function') return value.toJSON();
183
+ return value;
184
+ }
185
+
186
+ function _applyContentPolicy(data, settings) {
187
+ if (!data) return data;
188
+
189
+ let clone;
190
+ try {
191
+ clone = JSON.parse(JSON.stringify(data));
192
+ } catch {
193
+ return data;
194
+ }
195
+
196
+ if (settings.content_policy === 'ignore') {
197
+ for (const field of (settings.ignore_fields || [])) {
198
+ _deleteField(clone, field);
199
+ }
200
+ return clone;
201
+ }
202
+
203
+ return _pickFields(clone, settings.allow_fields || []);
204
+ }
205
+
206
+ function _pickFields(obj, allowFields) {
207
+ if (!obj || typeof obj !== 'object' || allowFields.length === 0) return obj;
208
+
209
+ const result = {};
210
+ for (const field of allowFields) {
211
+ const arrayMatch = field.match(/^(\w+)\[\]\.(.+)$/);
212
+ if (arrayMatch) {
213
+ const [, arrayKey, rest] = arrayMatch;
214
+ if (Array.isArray(obj[arrayKey])) {
215
+ result[arrayKey] = obj[arrayKey].map(item => _pickFields(item, [rest]));
216
+ }
217
+ continue;
218
+ }
219
+
220
+ const dotIdx = field.indexOf('.');
221
+ if (dotIdx === -1) {
222
+ if (field in obj) result[field] = obj[field];
223
+ } else {
224
+ const key = field.slice(0, dotIdx);
225
+ const rest = field.slice(dotIdx + 1);
226
+ if (key in obj) result[key] = _pickFields(obj[key], [rest]);
227
+ }
228
+ }
229
+ return result;
230
+ }
231
+
232
+ function _deleteField(obj, path) {
233
+ if (!obj || typeof obj !== 'object') return;
234
+
235
+ const arrayMatch = path.match(/^(\w+)\[\]\.(.+)$/);
236
+ if (arrayMatch) {
237
+ const [, arrayKey, rest] = arrayMatch;
238
+ if (Array.isArray(obj[arrayKey])) {
239
+ obj[arrayKey].forEach(item => _deleteField(item, rest));
240
+ }
241
+ return;
242
+ }
243
+
244
+ const dotIdx = path.indexOf('.');
245
+ if (dotIdx === -1) {
246
+ delete obj[path];
247
+ } else {
248
+ _deleteField(obj[path.slice(0, dotIdx)], path.slice(dotIdx + 1));
249
+ }
250
+ }
251
+
252
+ const _debug = process.env.WEFLAYR_DEBUG === 'true';
253
+
254
+ function _sendEvent(eventType, data) {
255
+ const { intakeUrl, clientId, clientSecret } = _config;
256
+ const endpoint = `${intakeUrl.replace(/\/$/, '')}/${clientId}/`;
257
+
258
+ let body;
259
+ try {
260
+ body = JSON.stringify({
261
+ event_type: eventType,
262
+ timestamp: new Date().toISOString(),
263
+ ...data,
264
+ });
265
+ } catch (err) {
266
+ console.warn(`[weflayr] could not serialize event "${eventType}":`, err.message);
267
+ return;
268
+ }
269
+
270
+ if (_debug) {
271
+ console.debug(`[weflayr] → ${eventType}`, JSON.parse(body));
272
+ }
273
+
274
+ let parsed;
275
+ try {
276
+ parsed = new URL(endpoint);
277
+ } catch {
278
+ return;
279
+ }
280
+
281
+ const lib = parsed.protocol === 'https:' ? https : http;
282
+ return new Promise((resolve) => {
283
+ const req = lib.request({
284
+ hostname: parsed.hostname,
285
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
286
+ path: parsed.pathname,
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ 'Content-Length': Buffer.byteLength(body),
291
+ 'Authorization': `Bearer ${clientSecret}`,
292
+ },
293
+ }, res => {
294
+ res.resume();
295
+ if (_debug) {
296
+ console.debug(`[weflayr] ← ${eventType} status ${res.statusCode}`);
297
+ }
298
+ if (res.statusCode >= 400) {
299
+ console.warn(`[weflayr] intake returned ${res.statusCode} for event "${eventType}"`);
300
+ }
301
+ resolve();
302
+ });
303
+
304
+ req.on('error', (err) => {
305
+ console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
306
+ resolve();
307
+ });
308
+
309
+ req.write(body);
310
+ req.end();
311
+ });
312
+ }
313
+
314
+ // Send an arbitrary event to the intake. Requires configure() to have been called first.
315
+ function send_event(eventType, data) {
316
+ if (!_config) return;
317
+ return _sendEvent(eventType, data);
318
+ }
319
+
320
+ module.exports = { configure, weflayr_instrument, send_event };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // Non-streaming: extract usage from the completed message response.
4
+ function middleware(request, response) {
5
+ if (!response) return { model: request?.model ?? null };
6
+ return {
7
+ model: response.model ?? request?.model ?? null,
8
+ input_tokens: response.usage?.input_tokens ?? null,
9
+ output_tokens: response.usage?.output_tokens ?? null,
10
+ cache_creation_input_tokens: response.usage?.cache_creation_input_tokens ?? null,
11
+ cache_read_input_tokens: response.usage?.cache_read_input_tokens ?? null,
12
+ };
13
+ }
14
+
15
+ // Streaming: Anthropic splits usage across two events.
16
+ // message_start → usage.input_tokens (prompt cost)
17
+ // message_delta → usage.output_tokens (completion cost, cumulative final value)
18
+ // message_stop → no usage data
19
+ // onChunk returns true only when usage state changes, so _wrapStream fires
20
+ // stream_pending exactly twice: once after message_start, once after message_delta.
21
+ function createStreamAccumulator() {
22
+ let input_tokens = null;
23
+ let output_tokens = null;
24
+ let cache_creation_input_tokens = null;
25
+ let cache_read_input_tokens = null;
26
+ let model = null;
27
+
28
+ return {
29
+ onChunk(chunk) {
30
+ if (chunk.type === 'message_start') {
31
+ const usage = chunk.message?.usage;
32
+ model = chunk.message?.model ?? null;
33
+ input_tokens = usage?.input_tokens ?? null;
34
+ cache_creation_input_tokens = usage?.cache_creation_input_tokens ?? null;
35
+ cache_read_input_tokens = usage?.cache_read_input_tokens ?? null;
36
+ return true;
37
+ }
38
+ if (chunk.type === 'message_delta') {
39
+ output_tokens = chunk.usage?.output_tokens ?? null;
40
+ return true;
41
+ }
42
+ return false;
43
+ },
44
+ finalize() {
45
+ return {
46
+ model,
47
+ input_tokens,
48
+ output_tokens,
49
+ cache_creation_input_tokens,
50
+ cache_read_input_tokens,
51
+ };
52
+ },
53
+ };
54
+ }
55
+
56
+ // Ready-to-use method config for client.messages.create.
57
+ const messagesCreate = {
58
+ call: 'messages.create',
59
+ middleware: middleware,
60
+ streamMiddleware: createStreamAccumulator,
61
+ };
62
+
63
+ module.exports = { messagesCreate, middleware, createStreamAccumulator };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('assert/strict');
5
+
6
+ // Reset module cache between tests so _config doesn't leak
7
+ function freshSDK() {
8
+ delete require.cache[require.resolve('../src/instrument')];
9
+ delete require.cache[require.resolve('../src/index')];
10
+ process.env.WEFLAYR_INTAKE_URL = 'http://localhost:19999'; // nothing listening — fire-and-forget is fine
11
+ process.env.WEFLAYR_CLIENT_ID = 'test-client-id';
12
+ process.env.WEFLAYR_CLIENT_SECRET = 'test-secret';
13
+ return require('../src/index');
14
+ }
15
+
16
+ // Minimal fake client: one async method at a nested path
17
+ function fakeClient() {
18
+ return {
19
+ chat: {
20
+ completions: {
21
+ create: async (args) => ({ id: 'res-1', model: args.model, choices: [] }),
22
+ },
23
+ },
24
+ };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Missing options don't crash
29
+ // ---------------------------------------------------------------------------
30
+
31
+ test('missing ignore_fields does not crash', async () => {
32
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
33
+
34
+ weflayr_setup({
35
+ event_mode: 'default',
36
+ content_policy: 'ignore',
37
+ // ignore_fields intentionally omitted
38
+ methods: [{ call: 'chat.completions.create' }],
39
+ });
40
+
41
+ const client = weflayr_instrument(fakeClient());
42
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
43
+
44
+ assert.equal(result.id, 'res-1');
45
+ });
46
+
47
+ test('missing allow_fields does not crash', async () => {
48
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
49
+
50
+ weflayr_setup({
51
+ event_mode: 'default',
52
+ content_policy: 'allow',
53
+ // allow_fields intentionally omitted
54
+ methods: [{ call: 'chat.completions.create' }],
55
+ });
56
+
57
+ const client = weflayr_instrument(fakeClient());
58
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
59
+
60
+ assert.equal(result.id, 'res-1');
61
+ });
62
+
63
+ test('missing methods does not crash on object instrumentation', async () => {
64
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
65
+
66
+ weflayr_setup({
67
+ event_mode: 'default',
68
+ content_policy: 'ignore',
69
+ // methods intentionally omitted
70
+ });
71
+
72
+ const client = weflayr_instrument(fakeClient());
73
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
74
+
75
+ assert.equal(result.id, 'res-1');
76
+ });
77
+
78
+ test('missing methods does not crash on function instrumentation', async () => {
79
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
80
+
81
+ weflayr_setup({
82
+ event_mode: 'default',
83
+ content_policy: 'ignore',
84
+ // methods intentionally omitted
85
+ });
86
+
87
+ async function myFn(x) { return x * 2; }
88
+ const instrumented = weflayr_instrument(myFn);
89
+
90
+ assert.equal(await instrumented(5), 10);
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // enabled: false — target passes through unchanged, calls still work
95
+ // ---------------------------------------------------------------------------
96
+
97
+ test('enabled:false returns the original object untouched', () => {
98
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
99
+
100
+ weflayr_setup({ methods: [{ call: 'chat.completions.create' }] }, { enabled: false });
101
+
102
+ const client = fakeClient();
103
+ const result = weflayr_instrument(client);
104
+
105
+ assert.strictEqual(result, client);
106
+ });
107
+
108
+ test('enabled:false returns the original function untouched', () => {
109
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
110
+
111
+ async function myFn(x) { return x * 2; }
112
+ weflayr_setup({ methods: [{ call: 'myFn' }] }, { enabled: false });
113
+
114
+ assert.strictEqual(weflayr_instrument(myFn), myFn);
115
+ });
116
+
117
+ test('enabled:false — original function still executes correctly', async () => {
118
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
119
+
120
+ weflayr_setup({ methods: [{ call: 'chat.completions.create' }] }, { enabled: false });
121
+
122
+ const client = weflayr_instrument(fakeClient());
123
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
124
+
125
+ assert.equal(result.id, 'res-1');
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Invalid content_policy falls back to allow — function still works correctly
130
+ // ---------------------------------------------------------------------------
131
+
132
+ test('invalid content_policy does not crash', async () => {
133
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
134
+
135
+ weflayr_setup({
136
+ event_mode: 'default',
137
+ content_policy: 'not_a_real_policy',
138
+ allow_fields: [],
139
+ methods: [{ call: 'chat.completions.create' }],
140
+ });
141
+
142
+ const client = weflayr_instrument(fakeClient());
143
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
144
+
145
+ assert.equal(result.id, 'res-1');
146
+ });
147
+
148
+ test('invalid content_policy does not apply ignore_fields', async () => {
149
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
150
+
151
+ let capturedArgs;
152
+ const client = {
153
+ chat: {
154
+ completions: {
155
+ create: async (args) => { capturedArgs = args; return { id: 'res-1' }; },
156
+ },
157
+ },
158
+ };
159
+
160
+ weflayr_setup({
161
+ event_mode: 'default',
162
+ content_policy: 'not_a_real_policy',
163
+ ignore_fields: ['messages'],
164
+ allow_fields: [],
165
+ methods: [{ call: 'chat.completions.create' }],
166
+ });
167
+
168
+ const instrumented = weflayr_instrument(client);
169
+ await instrumented.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hello' }] });
170
+
171
+ // ignore_fields must NOT have been applied — the real call receives the original args
172
+ assert.ok(Array.isArray(capturedArgs.messages));
173
+ assert.equal(capturedArgs.messages[0].content, 'hello');
174
+ });
175
+
176
+ test('undefined content_policy falls back to allow (pass-through)', async () => {
177
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
178
+
179
+ weflayr_setup({
180
+ event_mode: 'default',
181
+ // content_policy intentionally omitted
182
+ ignore_fields: ['messages'],
183
+ allow_fields: [],
184
+ methods: [{ call: 'chat.completions.create' }],
185
+ });
186
+
187
+ const client = weflayr_instrument(fakeClient());
188
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
189
+
190
+ assert.equal(result.id, 'res-1');
191
+ });
package/src/openai.js DELETED
@@ -1,116 +0,0 @@
1
- import { trace } from "@opentelemetry/api";
2
- import { setupWeflayr, makeWrapper, deepFallbackProxy } from "./weflayr.js";
3
-
4
- export { setupWeflayr };
5
-
6
- // ── STT usage helper ──────────────────────────────────────────────────────────
7
- function sttUsage(r) {
8
- const usage = r?.usage;
9
- if (!usage) return {};
10
- if (usage.type === "tokens") {
11
- return {
12
- usage_type: "tokens",
13
- input_tokens: usage.input_tokens,
14
- audio_tokens: usage.input_token_details?.audio_tokens,
15
- };
16
- }
17
- if (usage.type === "duration") {
18
- return { usage_type: "duration", duration_seconds: usage.seconds };
19
- }
20
- return {};
21
- }
22
-
23
- // ── Route map ─────────────────────────────────────────────────────────────────
24
- // Each entry defines one instrumented OpenAI method:
25
- // name — event_type prefix sent to Weflayr
26
- // get — retrieves the original bound method from the client
27
- // set — patches the method back on the client
28
- // before — extracts fields from call params for the .before event
29
- // after — extracts fields from the response for the .after event
30
-
31
- const ROUTES = [
32
- // client.chat.completions.create
33
- {
34
- name: "chat.completions.create",
35
- get: (c) => c.chat.completions.create.bind(c.chat.completions),
36
- set: (c, fn) => { c.chat.completions.create = fn; },
37
- before: (p) => ({ model: p.model, message_count: p.messages?.length ?? 0 }),
38
- after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, completion_tokens: r.usage?.completion_tokens }),
39
- },
40
-
41
- // client.completions.create (legacy text completions)
42
- {
43
- name: "completions.create",
44
- get: (c) => c.completions.create.bind(c.completions),
45
- set: (c, fn) => { c.completions.create = fn; },
46
- before: (p) => ({ model: p.model, prompt_length: typeof p.prompt === "string" ? p.prompt.length : (p.prompt?.length ?? 0) }),
47
- after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, completion_tokens: r.usage?.completion_tokens }),
48
- },
49
-
50
- // client.embeddings.create
51
- {
52
- name: "embeddings.create",
53
- get: (c) => c.embeddings.create.bind(c.embeddings),
54
- set: (c, fn) => { c.embeddings.create = fn; },
55
- before: (p) => ({ model: p.model, input_count: Array.isArray(p.input) ? p.input.length : 1 }),
56
- after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, total_tokens: r.usage?.total_tokens }),
57
- },
58
-
59
- // client.responses.create (Responses API)
60
- {
61
- name: "responses.create",
62
- get: (c) => c.responses.create.bind(c.responses),
63
- set: (c, fn) => { c.responses.create = fn; },
64
- before: (p) => ({ model: p.model, input_count: Array.isArray(p.input) ? p.input.length : 1 }),
65
- after: (r) => ({
66
- input_tokens: r.usage?.input_tokens,
67
- output_tokens: r.usage?.output_tokens,
68
- cached_tokens: r.usage?.input_tokens_details?.cached_tokens,
69
- }),
70
- },
71
-
72
- // client.audio.speech.create (TTS — billed by char count)
73
- {
74
- name: "audio.speech.create",
75
- get: (c) => c.audio.speech.create.bind(c.audio.speech),
76
- set: (c, fn) => { c.audio.speech.create = fn; },
77
- before: (p) => ({ model: p.model, voice: p.voice, char_count: p.input?.length ?? 0 }),
78
- after: () => ({}),
79
- },
80
-
81
- // client.audio.transcriptions.create (STT)
82
- {
83
- name: "audio.transcriptions.create",
84
- get: (c) => c.audio.transcriptions.create.bind(c.audio.transcriptions),
85
- set: (c, fn) => { c.audio.transcriptions.create = fn; },
86
- before: (p) => ({ model: p.model, language: p.language }),
87
- after: (r) => sttUsage(r),
88
- },
89
-
90
- // client.audio.translations.create (whisper-1 only — billed by duration)
91
- {
92
- name: "audio.translations.create",
93
- get: (c) => c.audio.translations.create.bind(c.audio.translations),
94
- set: (c, fn) => { c.audio.translations.create = fn; },
95
- before: (p) => ({ model: p.model }),
96
- after: (r) => sttUsage(r),
97
- },
98
- ];
99
- // ─────────────────────────────────────────────────────────────────────────────
100
-
101
- // Paths already covered by ROUTES — the fallback Proxy skips these.
102
- const PATCHED_PATHS = new Set(ROUTES.map((r) => r.name));
103
-
104
- export function instrument(client) {
105
- const tracer = trace.getTracer("weflayr-openai");
106
-
107
- const providerOpts = { provider: "openai" };
108
-
109
- // 1. Patch all explicitly defined routes with their precise extractors.
110
- for (const route of ROUTES) {
111
- route.set(client, makeWrapper(route.get(client), route, tracer, providerOpts));
112
- }
113
-
114
- // 2. Wrap the whole client in a Proxy that handles anything not in ROUTES.
115
- return deepFallbackProxy(client, tracer, PATCHED_PATHS, "", providerOpts);
116
- }
package/src/weflayr.js DELETED
@@ -1,229 +0,0 @@
1
- import "dotenv/config";
2
- import { SpanStatusCode } from "@opentelemetry/api";
3
- import {
4
- BasicTracerProvider,
5
- SimpleSpanProcessor,
6
- } from "@opentelemetry/sdk-trace-base";
7
- import { randomUUID } from "node:crypto";
8
-
9
- // ── Weflayr Intake API ────────────────────────────────────────────────────────
10
- export const INTAKE_URL = (
11
- process.env.WEFLAYR_INTAKE_URL ?? "https://api.weflayr.com"
12
- ).replace(/\/$/, "");
13
- export const CLIENT_ID = process.env.WEFLAYR_CLIENT_ID ?? "";
14
- export const CLIENT_SECRET = process.env.WEFLAYR_CLIENT_SECRET ?? "";
15
-
16
- // ── INFO TO SEND ──────────────────────────────────────────────────────────────
17
- // Global tags attached to every event. Populate via env vars or hardcode values.
18
- // Per-call tags can also be passed directly in the create() params (see main.js).
19
- export const GLOBAL_TAGS = Object.fromEntries(
20
- Object.entries({
21
- env: process.env.WEFLAYR_TAG_ENV,
22
- feature: process.env.WEFLAYR_TAG_FEATURE,
23
- version: process.env.WEFLAYR_TAG_VERSION,
24
- }).filter(([, v]) => v != null)
25
- );
26
-
27
- // ── INFO TO HIDE ──────────────────────────────────────────────────────────────
28
- // Keys stripped from every Weflayr event before it is sent.
29
- // Prevents PII or sensitive content from leaving the process.
30
- export const HIDDEN_FIELDS = new Set([
31
- "messages", // prompt message content (chat completions)
32
- "prompt", // prompt text (legacy completions)
33
- "response_content", // completion response text
34
- ]);
35
- // ─────────────────────────────────────────────────────────────────────────────
36
-
37
- export async function _post(payload) {
38
- if (!CLIENT_ID || !CLIENT_SECRET) return;
39
-
40
- for (const key of HIDDEN_FIELDS) delete payload[key];
41
- for (const [k, v] of Object.entries(payload)) {
42
- if (v == null) delete payload[k];
43
- }
44
-
45
- try {
46
- await fetch(`${INTAKE_URL}/${CLIENT_ID}/`, {
47
- method: "POST",
48
- headers: {
49
- "Content-Type": "application/json",
50
- Authorization: `Bearer ${CLIENT_SECRET}`,
51
- },
52
- body: JSON.stringify(payload),
53
- });
54
- } catch {
55
- // fire-and-forget — never throws
56
- }
57
- }
58
-
59
- export class WeflayrSpanProcessor {
60
- onStart(span) {
61
- const a = span.attributes;
62
- const before = JSON.parse(a["weflayr.before"] ?? "{}");
63
- const tags = JSON.parse(a["weflayr.tags"] ?? "{}");
64
- _post({
65
- event_id: a["weflayr.event_id"],
66
- event_type: `${span.name}.before`,
67
- ...before,
68
- tags: Object.keys(tags).length ? tags : undefined,
69
- });
70
- }
71
-
72
- onEnd(span) {
73
- const [ss, sns] = span.startTime;
74
- const [es, ens] = span.endTime;
75
- const elapsed_ms = Math.round((es - ss) * 1000 + (ens - sns) / 1_000_000);
76
-
77
- const isError = span.status.code === SpanStatusCode.ERROR;
78
- const a = span.attributes;
79
- const before = JSON.parse(a["weflayr.before"] ?? "{}");
80
- const after = JSON.parse(a["weflayr.after"] ?? "{}");
81
- const tags = JSON.parse(a["weflayr.tags"] ?? "{}");
82
-
83
- _post({
84
- event_id: a["weflayr.event_id"],
85
- event_type: `${span.name}.${isError ? "error" : "after"}`,
86
- ...before,
87
- ...after,
88
- elapsed_ms,
89
- tags: Object.keys(tags).length ? tags : undefined,
90
- ...(isError
91
- ? { error_type: a["error.type"], error_message: a["error.message"] }
92
- : {}),
93
- });
94
- }
95
-
96
- forceFlush() { return Promise.resolve(); }
97
- shutdown() { return Promise.resolve(); }
98
- }
99
-
100
- export async function setupWeflayr() {
101
- // Direct path: always send to the Weflayr Intake API
102
- const spanProcessors = [new WeflayrSpanProcessor()];
103
-
104
- // Optional collector path: also forward OTLP traces to your own collector.
105
- // Set WEFLAYR_COLLECTOR_ENDPOINT in .env to enable (e.g. http://localhost:4318/v1/traces).
106
- if (process.env.WEFLAYR_COLLECTOR_ENDPOINT) {
107
- const { OTLPTraceExporter } = await import(
108
- "@opentelemetry/exporter-trace-otlp-http"
109
- );
110
- spanProcessors.push(
111
- new SimpleSpanProcessor(
112
- new OTLPTraceExporter({ url: process.env.WEFLAYR_COLLECTOR_ENDPOINT })
113
- )
114
- );
115
- }
116
-
117
- new BasicTracerProvider({ spanProcessors }).register();
118
- }
119
-
120
- export function makeWrapper(original, route, tracer, { provider } = {}) {
121
- return async function ({ tags: callTags, ...params }) {
122
- const tags = { ...GLOBAL_TAGS, ...callTags };
123
- const before = { ...(provider ? { provider } : {}), ...route.before(params) };
124
- const span = tracer.startSpan(route.name, {
125
- attributes: {
126
- "weflayr.event_id": randomUUID(),
127
- "weflayr.before": JSON.stringify(before),
128
- "weflayr.tags": JSON.stringify(tags),
129
- },
130
- });
131
-
132
- try {
133
- const response = await original(params);
134
- span.setAttribute("weflayr.after", JSON.stringify(route.after(response)));
135
- span.setStatus({ code: SpanStatusCode.OK });
136
- return response;
137
- } catch (err) {
138
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
139
- span.setAttribute("error.type", err.constructor.name);
140
- span.setAttribute("error.message", err.message);
141
- throw err;
142
- } finally {
143
- span.end();
144
- }
145
- };
146
- }
147
-
148
- export function makeFallbackWrapper(fn, target, name, tracer, { provider } = {}) {
149
- return function (...args) {
150
- let callArgs = args;
151
- let tags = GLOBAL_TAGS;
152
-
153
- // If the first argument is a plain params object, extract `tags` from it.
154
- if (args[0] !== null && typeof args[0] === "object" && !Array.isArray(args[0])) {
155
- const { tags: callTags, ...rest } = args[0];
156
- callArgs = [rest, ...args.slice(1)];
157
- tags = { ...GLOBAL_TAGS, ...callTags };
158
- }
159
-
160
- const before = { ...(provider ? { provider } : {}), model: callArgs[0]?.model };
161
- const span = tracer.startSpan(name, {
162
- attributes: {
163
- "weflayr.event_id": randomUUID(),
164
- "weflayr.before": JSON.stringify(before),
165
- "weflayr.tags": JSON.stringify(tags),
166
- },
167
- });
168
-
169
- let result;
170
- try {
171
- result = fn.apply(target, callArgs);
172
- } catch (err) {
173
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
174
- span.setAttribute("error.type", err.constructor.name);
175
- span.setAttribute("error.message", err.message);
176
- span.end();
177
- throw err;
178
- }
179
-
180
- // Handle both sync and async return values.
181
- if (result && typeof result.then === "function") {
182
- return result.then(
183
- (response) => {
184
- span.setAttribute("weflayr.after", "{}");
185
- span.setStatus({ code: SpanStatusCode.OK });
186
- span.end();
187
- return response;
188
- },
189
- (err) => {
190
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
191
- span.setAttribute("error.type", err.constructor.name);
192
- span.setAttribute("error.message", err.message);
193
- span.end();
194
- throw err;
195
- }
196
- );
197
- }
198
-
199
- span.setAttribute("weflayr.after", "{}");
200
- span.setStatus({ code: SpanStatusCode.OK });
201
- span.end();
202
- return result;
203
- };
204
- }
205
-
206
- // Recursively wraps every function on `obj` that is not already covered by patchedPaths.
207
- // `path` tracks the dotted property path (e.g. "images.generate").
208
- export function deepFallbackProxy(obj, tracer, patchedPaths, path = "", opts = {}) {
209
- return new Proxy(obj, {
210
- get(target, prop) {
211
- if (typeof prop !== "string") return Reflect.get(target, prop);
212
-
213
- const val = Reflect.get(target, prop);
214
- const fullPath = path ? `${path}.${prop}` : prop;
215
-
216
- if (typeof val === "function") {
217
- // Already patched by explicit routes — return the existing wrapper as-is.
218
- if (patchedPaths.has(fullPath)) return val;
219
- return makeFallbackWrapper(val, target, fullPath, tracer, opts);
220
- }
221
-
222
- if (val !== null && typeof val === "object") {
223
- return deepFallbackProxy(val, tracer, patchedPaths, fullPath, opts);
224
- }
225
-
226
- return val;
227
- },
228
- });
229
- }