weflayr 0.2.0 → 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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "weflayr",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Weflayr Node.js SDK — instrument any LLM client via JS Proxy",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
- "test": "node --test tests/"
7
+ "test": "node --test tests/index.test.js"
8
8
  },
9
9
  "keywords": ["llm", "observability", "weflayr"],
10
10
  "author": "Weflayr <contact@weflayr.com>",
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require('dotenv').config();
4
4
 
5
- const { configure, weflayr_instrument } = require('./instrument');
5
+ const { configure, weflayr_instrument, send_event } = require('./instrument');
6
6
 
7
7
  /**
8
8
  *
@@ -26,4 +26,4 @@ function weflayr_setup(settings, { enabled = true } = {}) {
26
26
  configure(intakeUrl, clientId, clientSecret, settings);
27
27
  }
28
28
 
29
- module.exports = { weflayr_setup, weflayr_instrument };
29
+ module.exports = { weflayr_setup, weflayr_instrument, send_event };
package/src/instrument.js CHANGED
@@ -14,7 +14,7 @@ function weflayr_instrument(target) {
14
14
  if (!_config) return target;
15
15
 
16
16
  if (typeof target === 'function') {
17
- const methodConfig = _config.settings.methods.find(m => m.call === target.name);
17
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === target.name);
18
18
  if (!methodConfig) return target;
19
19
  return _wrapFn(target, target.name, methodConfig);
20
20
  }
@@ -35,7 +35,7 @@ function _makeProxy(target, pathPrefix) {
35
35
  const fullPath = pathPrefix ? `${pathPrefix}.${prop}` : prop;
36
36
 
37
37
  if (typeof value === 'function') {
38
- const methodConfig = _config.settings.methods.find(m => m.call === fullPath);
38
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === fullPath);
39
39
  if (methodConfig) {
40
40
  return _wrapFn(value.bind(obj), fullPath, methodConfig);
41
41
  }
@@ -57,16 +57,19 @@ function _wrapFn(fn, methodName, methodConfig) {
57
57
  const startTime = Date.now();
58
58
  const { settings } = _config;
59
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];
60
63
 
61
64
  if (settings.event_mode !== 'light') {
62
65
  const beforeMiddlewareData = methodConfig.middleware
63
- ? methodConfig.middleware(cleanArgs[0], null) || {}
66
+ ? methodConfig.middleware(requestArgs, null) || {}
64
67
  : {};
65
68
  _sendEvent('before', {
66
69
  event_id: eventId,
67
70
  method: methodName,
68
71
  tags,
69
- args: _applyContentPolicy(cleanArgs[0], settings),
72
+ args: _applyContentPolicy(requestArgs, settings),
70
73
  ...beforeMiddlewareData,
71
74
  });
72
75
  }
@@ -76,18 +79,18 @@ function _wrapFn(fn, methodName, methodConfig) {
76
79
 
77
80
  if (_isAsyncIterable(result)) {
78
81
  _sendEvent('stream_start', { event_id: eventId, method: methodName, tags });
79
- return _wrapStream(result, methodName, tags, cleanArgs[0], settings, startTime, methodConfig, eventId);
82
+ return _wrapStream(result, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId);
80
83
  }
81
84
 
82
85
  const middlewareData = methodConfig.middleware
83
- ? methodConfig.middleware(cleanArgs[0], result) || {}
86
+ ? methodConfig.middleware(requestArgs, result) || {}
84
87
  : {};
85
88
 
86
89
  _sendEvent('after', {
87
90
  event_id: eventId,
88
91
  method: methodName,
89
92
  tags,
90
- args: _applyContentPolicy(cleanArgs[0], settings),
93
+ args: _applyContentPolicy(requestArgs, settings),
91
94
  response: _applyContentPolicy(_toPlain(result), settings),
92
95
  elapsed_ms: Date.now() - startTime,
93
96
  ...middlewareData,
@@ -95,13 +98,13 @@ function _wrapFn(fn, methodName, methodConfig) {
95
98
 
96
99
  return result;
97
100
  } catch (err) {
98
- _sendEvent('after', {
101
+ await _sendEvent('after', {
99
102
  event_id: eventId,
100
103
  method: methodName,
101
104
  tags,
102
- args: _applyContentPolicy(cleanArgs[0], settings),
103
- error: err.message,
104
- status_code: err.status,
105
+ args: _applyContentPolicy(requestArgs, settings),
106
+ error: err.message ?? 'unknown',
107
+ status_code: err.status ?? err.statusCode ?? 'unknown',
105
108
  elapsed_ms: Date.now() - startTime,
106
109
  });
107
110
  throw err;
@@ -111,16 +114,27 @@ function _wrapFn(fn, methodName, methodConfig) {
111
114
 
112
115
  async function* _wrapStream(stream, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId) {
113
116
  let lastChunk = null;
117
+ const accumulator = methodConfig.streamMiddleware ? methodConfig.streamMiddleware() : null;
114
118
 
115
119
  try {
116
120
  for await (const chunk of stream) {
117
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
+ }
118
131
  yield chunk;
119
132
  }
120
133
 
121
- const middlewareData = methodConfig.middleware && lastChunk
122
- ? methodConfig.middleware(requestArgs, lastChunk) || {}
123
- : {};
134
+ const middlewareData = accumulator
135
+ ? accumulator.finalize()
136
+ : (methodConfig.middleware && lastChunk ? methodConfig.middleware(requestArgs, lastChunk) || {}
137
+ : {});
124
138
 
125
139
  _sendEvent('after', {
126
140
  event_id: eventId,
@@ -132,12 +146,13 @@ async function* _wrapStream(stream, methodName, tags, requestArgs, settings, sta
132
146
  ...middlewareData,
133
147
  });
134
148
  } catch (err) {
135
- _sendEvent('after', {
149
+ await _sendEvent('after', {
136
150
  event_id: eventId,
137
151
  method: methodName,
138
152
  tags,
139
153
  args: _applyContentPolicy(requestArgs, settings),
140
- error: err.message,
154
+ error: err.message ?? 'unknown',
155
+ status_code: err.status ?? err.statusCode ?? 'unknown',
141
156
  elapsed_ms: Date.now() - startTime,
142
157
  });
143
158
  throw err;
@@ -163,6 +178,7 @@ function _isAsyncIterable(value) {
163
178
 
164
179
  function _toPlain(value) {
165
180
  if (!value) return value;
181
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof ArrayBuffer) return null;
166
182
  if (typeof value.toJSON === 'function') return value.toJSON();
167
183
  return value;
168
184
  }
@@ -177,15 +193,14 @@ function _applyContentPolicy(data, settings) {
177
193
  return data;
178
194
  }
179
195
 
180
- if (settings.content_policy === 'allow') {
181
- return _pickFields(clone, settings.allow_fields || []);
182
- }
183
-
184
- for (const field of (settings.ignore_fields || [])) {
185
- _deleteField(clone, field);
196
+ if (settings.content_policy === 'ignore') {
197
+ for (const field of (settings.ignore_fields || [])) {
198
+ _deleteField(clone, field);
199
+ }
200
+ return clone;
186
201
  }
187
202
 
188
- return clone;
203
+ return _pickFields(clone, settings.allow_fields || []);
189
204
  }
190
205
 
191
206
  function _pickFields(obj, allowFields) {
@@ -234,15 +249,27 @@ function _deleteField(obj, path) {
234
249
  }
235
250
  }
236
251
 
252
+ const _debug = process.env.WEFLAYR_DEBUG === 'true';
253
+
237
254
  function _sendEvent(eventType, data) {
238
255
  const { intakeUrl, clientId, clientSecret } = _config;
239
256
  const endpoint = `${intakeUrl.replace(/\/$/, '')}/${clientId}/`;
240
257
 
241
- const body = JSON.stringify({
242
- event_type: eventType,
243
- timestamp: new Date().toISOString(),
244
- ...data,
245
- });
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
+ }
246
273
 
247
274
  let parsed;
248
275
  try {
@@ -252,30 +279,42 @@ function _sendEvent(eventType, data) {
252
279
  }
253
280
 
254
281
  const lib = parsed.protocol === 'https:' ? https : http;
255
- const req = lib.request({
256
- hostname: parsed.hostname,
257
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
258
- path: parsed.pathname,
259
- method: 'POST',
260
- headers: {
261
- 'Content-Type': 'application/json',
262
- 'Content-Length': Buffer.byteLength(body),
263
- 'Authorization': `Bearer ${clientSecret}`,
264
- },
265
- }, res => {
266
- res.resume();
267
- if (res.statusCode >= 400) {
268
- console.warn(`[weflayr] intake returned ${res.statusCode} for event "${eventType}"`);
269
- }
270
- });
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
+ });
271
303
 
272
- req.on('error', (err) => {
273
- console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
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();
274
311
  });
312
+ }
275
313
 
276
- req.write(body);
277
-
278
- req.end();
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);
279
318
  }
280
319
 
281
- module.exports = { configure, weflayr_instrument };
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
+ });