weflayr 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Weflayr Node.js SDK
2
2
 
3
- Observability for Node.js - instrument any client or method with one line.
3
+ Observability for Node.js - instrument any LLM client or function with one line.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,33 +8,23 @@ Observability for Node.js - instrument any client or method with one line.
8
8
  npm install weflayr
9
9
  ```
10
10
 
11
- ## Environment variables
12
-
13
- ```bash
14
- WEFLAYR_INTAKE_URL=https://api.weflayr.com
15
- WEFLAYR_CLIENT_ID=your-client-id-uuid
16
- WEFLAYR_CLIENT_SECRET=your-client-secret
17
- ```
18
-
19
- ## Usage
11
+ ## Quick start
20
12
 
21
13
  ```js
22
14
  const { weflayr_setup, weflayr_instrument } = require('weflayr');
23
15
 
24
16
  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: [],
17
+ intake_url: 'https://api.weflayr.com',
18
+ client_id: 'your-client-id-uuid',
19
+ client_secret: 'your-client-secret',
29
20
  methods: [
30
- { call: 'chat.completions.create' }
21
+ { call: 'chat.completions.create' },
31
22
  ],
32
- }, { enabled: true });
23
+ });
33
24
 
34
25
  const OpenAI = require('openai');
35
26
  const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
36
27
 
37
- // Tags are stripped from args before the API call and attached to the event
38
28
  const response = await client.chat.completions.create({
39
29
  model: 'gpt-4o-mini',
40
30
  messages: [{ role: 'user', content: 'Hello' }],
@@ -42,6 +32,108 @@ const response = await client.chat.completions.create({
42
32
  });
43
33
  ```
44
34
 
35
+ Credentials are typically read from environment variables via `dotenv`:
36
+
37
+ ```js
38
+ weflayr_setup({
39
+ intake_url: process.env.WEFLAYR_INTAKE_URL,
40
+ client_id: process.env.WEFLAYR_CLIENT_ID,
41
+ client_secret: process.env.WEFLAYR_CLIENT_SECRET,
42
+ methods: [...],
43
+ });
44
+ ```
45
+
46
+ ```bash
47
+ # .env
48
+ WEFLAYR_INTAKE_URL=https://api.weflayr.com
49
+ WEFLAYR_CLIENT_ID=your-client-id-uuid
50
+ WEFLAYR_CLIENT_SECRET=your-client-secret
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Settings reference
56
+
57
+ | Field | Type | Required | Description |
58
+ |---|---|---|---|
59
+ | `intake_url` | `string` | ✓ | Base URL of the Weflayr intake API |
60
+ | `client_id` | `string` | ✓ | UUID identifying your Flare credential pair |
61
+ | `client_secret` | `string` | ✓ | Bearer token used to authenticate events |
62
+ | `event_mode` | `'default'` \| `'light'` | | `light` skips `before` events. Default: `'default'` |
63
+ | `enabled` | `boolean` | | Set to `false` to disable instrumentation entirely. Default: `true` |
64
+ | `ignore_fields` | `function` | | Middleware to strip sensitive fields from event payloads. Mutually exclusive with `allow_fields`. |
65
+ | `allow_fields` | `function` | | Middleware to keep only approved fields in event payloads. Mutually exclusive with `ignore_fields`. |
66
+ | `methods` | `MethodConfig[]` | | Methods to instrument on the proxied object |
67
+
68
+ ---
69
+
70
+ ## Filtering event payloads
71
+
72
+ `ignore_fields` and `allow_fields` are **middleware functions**, not field lists. Each receives a deep clone of the event payload (request args or response body) and returns the filtered version. The original args forwarded to the real provider call are never affected.
73
+
74
+ Only one may be set. Setting both logs a warning and blocks all events.
75
+
76
+ ### ignore_fields strip specific fields
77
+
78
+ ```js
79
+ weflayr_setup({
80
+ // ...
81
+ ignore_fields: (data) => {
82
+ (data.messages ?? []).forEach(m => delete m.content);
83
+ (data.choices ?? []).forEach(c => { if (c.message) delete c.message.content; });
84
+ return data;
85
+ },
86
+ methods: [{ call: 'chat.completions.create' }],
87
+ });
88
+ ```
89
+
90
+ ### allow_fields keep only specific fields
91
+
92
+ ```js
93
+ weflayr_setup({
94
+ // ...
95
+ allow_fields: (data) => ({
96
+ model: data.model,
97
+ usage: data.usage,
98
+ }),
99
+ methods: [{ call: 'chat.completions.create' }],
100
+ });
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Metadata tags
106
+
107
+ Pass `__weflayr_tags` on any instrumented call to attach arbitrary key-value metadata to the event. Tags are stripped before the real provider call is made.
108
+
109
+ ```js
110
+ await client.chat.completions.create({
111
+ model: 'gpt-4o-mini',
112
+ messages: [...],
113
+ __weflayr_tags: { feature: 'summarise', customer_id: '42' },
114
+ });
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Middleware
120
+
121
+ Middleware extracts structured fields that are merged into the event payload. `response` is `null` for `before` events.
122
+
123
+ ```js
124
+ methods: [
125
+ {
126
+ call: 'audio.speech.create',
127
+ middleware: (args, response) => ({
128
+ char_count: args?.input?.length ?? 0,
129
+ result_count: response?.data?.length ?? 0,
130
+ }),
131
+ },
132
+ ],
133
+ ```
134
+
135
+ ---
136
+
45
137
  ## Instrumenting a plain function
46
138
 
47
139
  ```js
@@ -51,42 +143,83 @@ async function fetchModels() {
51
143
  }
52
144
 
53
145
  weflayr_setup({
54
- event_mode: 'default',
55
- content_policy: 'ignore',
56
- ignore_fields: ['data'],
146
+ // ...
57
147
  methods: [
58
148
  {
59
149
  call: 'fetchModels',
60
150
  middleware: (_args, response) => ({ count: response?.data?.length ?? 0 }),
61
151
  },
62
152
  ],
63
- }, { enabled: true });
153
+ });
64
154
 
65
155
  const instrumented = weflayr_instrument(fetchModels);
66
156
  const models = await instrumented({ __weflayr_tags: { app: 'my-app' } });
67
157
  ```
68
158
 
69
- ## Settings reference
159
+ ---
70
160
 
71
- | Field | Type | Description |
72
- |---|---|---|
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 |
161
+ ## Disabling instrumentation
78
162
 
79
- Field paths support dot notation (`foo.bar`), and array traversal (`messages[].content`).
163
+ ```js
164
+ weflayr_setup({
165
+ // ...
166
+ enabled: false,
167
+ });
168
+ ```
80
169
 
81
- ## Middleware
170
+ `weflayr_instrument` returns the original object untouched when `enabled` is `false`.
82
171
 
83
- Middleware runs on both **before** and **after** events. `response` is `null` for before events.
172
+ ---
84
173
 
85
- ```js
86
- middleware: (args, response) => ({
87
- char_count: args?.input?.length ?? 0,
88
- result_count: response?.data?.length ?? 0,
89
- })
174
+ ## TypeScript
175
+
176
+ The SDK ships with full type declarations. No additional `@types` package is needed.
177
+
178
+ ```ts
179
+ import { weflayr_setup, weflayr_instrument, flayred } from 'weflayr';
180
+ import OpenAI from 'openai';
181
+
182
+ weflayr_setup({
183
+ intake_url: process.env.WEFLAYR_INTAKE_URL!,
184
+ client_id: process.env.WEFLAYR_CLIENT_ID!,
185
+ client_secret: process.env.WEFLAYR_CLIENT_SECRET!,
186
+ methods: [{ call: 'chat.completions.create' }],
187
+ });
188
+
189
+ const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
90
190
  ```
91
191
 
92
- The returned object is merged into the event payload.
192
+ ### Adding tags in TypeScript
193
+
194
+ Because `__weflayr_tags` is not part of the provider SDK's types, use the `flayred` helper to attach tags without casting:
195
+
196
+ ```ts
197
+ const response = await client.chat.completions.create(flayred({
198
+ model: 'gpt-4o-mini',
199
+ messages: [{ role: 'user', content: 'Hello' }],
200
+ __weflayr_tags: { feature: 'chat', customer_id: '42' },
201
+ }));
202
+ ```
203
+
204
+ `flayred<T>(options: T & { __weflayr_tags? }): T` TypeScript sees the return type as `T`, so it is fully compatible with the provider SDK's expected parameter type.
205
+
206
+ ### Typing middleware
207
+
208
+ Cast inside the middleware body the SDK is provider-agnostic so it cannot infer the concrete response type:
209
+
210
+ ```ts
211
+ import OpenAI from 'openai';
212
+
213
+ methods: [
214
+ {
215
+ call: 'chat.completions.create',
216
+ middleware: (_args, response) => {
217
+ const r = response as OpenAI.ChatCompletion | null;
218
+ return {
219
+ prompt_tokens: r?.usage?.prompt_tokens ?? 0,
220
+ completion_tokens: r?.usage?.completion_tokens ?? 0,
221
+ };
222
+ },
223
+ },
224
+ ],
225
+ ```
package/index.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ /** Arbitrary key-value metadata attached to an LLM call. */
2
+ export type Tags = Record<string, string | number | boolean>;
3
+
4
+ /**
5
+ * Receives a deep clone of the request args or response, mutates or replaces
6
+ * fields, and returns the filtered object sent to the intake API.
7
+ */
8
+ export type ContentPolicyFn = (data: Record<string, unknown>) => Record<string, unknown>;
9
+
10
+ /**
11
+ * Extracts extra structured fields merged into the intake event.
12
+ * Called with `(args, null)` for the `before` event and `(args, result)` for `after`.
13
+ */
14
+ export type MiddlewareFn = (
15
+ args: Record<string, unknown>,
16
+ response: Record<string, unknown> | null,
17
+ ) => Record<string, unknown>;
18
+
19
+ /** Per-chunk accumulator returned by a `streamMiddleware` factory. */
20
+ export interface StreamAccumulator {
21
+ /** Return `true` to emit a `stream_pending` event immediately. */
22
+ onChunk: (chunk: unknown) => boolean;
23
+ /** Called once the stream ends; returns extra fields for the `after` event. */
24
+ finalize: () => Record<string, unknown>;
25
+ }
26
+
27
+ /** Factory called once per stream invocation. */
28
+ export type StreamMiddlewareFn = () => StreamAccumulator;
29
+
30
+ /** Instrumentation configuration for a single method path. */
31
+ export interface MethodConfig {
32
+ /**
33
+ * Dot-separated path to the method on the instrumented object
34
+ * (e.g. `'chat.completions.create'`), or the function name for bare functions.
35
+ */
36
+ call: string;
37
+ middleware?: MiddlewareFn;
38
+ streamMiddleware?: StreamMiddlewareFn;
39
+ }
40
+
41
+ /** Full configuration object passed to `weflayr_setup`. */
42
+ export interface WeflayrSettings {
43
+ /** Base URL of the Weflayr intake API (e.g. `'https://api.weflayr.com'`). */
44
+ intake_url: string;
45
+ /** UUID identifying the Flare credential pair. */
46
+ client_id: string;
47
+ /** Bearer token used when calling the intake API. */
48
+ client_secret: string;
49
+ /** `'default'` emits before + after events; `'light'` emits only after. */
50
+ event_mode?: 'default' | 'light';
51
+ /** Set to `false` to disable instrumentation entirely. Defaults to `true`. */
52
+ enabled?: boolean;
53
+ /**
54
+ * Middleware to strip sensitive fields from event payloads before sending to the intake API.
55
+ * Mutually exclusive with `allow_fields` — setting both blocks all events.
56
+ */
57
+ ignore_fields?: ContentPolicyFn;
58
+ /**
59
+ * Middleware to keep only approved fields in event payloads before sending to the intake API.
60
+ * Mutually exclusive with `ignore_fields` — setting both blocks all events.
61
+ */
62
+ allow_fields?: ContentPolicyFn;
63
+ /** Methods to instrument on the proxied object. */
64
+ methods?: MethodConfig[];
65
+ }
66
+
67
+ /** Configures weflayr with your credentials and instrumentation settings. */
68
+ export function weflayr_setup(settings: WeflayrSettings): void;
69
+
70
+ /**
71
+ * Wraps an LLM client object or async function so matching method calls emit
72
+ * events to the intake API. Returns the original value unchanged when weflayr
73
+ * is disabled or the target has no matching method config.
74
+ */
75
+ export function weflayr_instrument<T>(target: T): T;
76
+
77
+ /** Sends a custom event to the intake API from user code. No-ops if `weflayr_setup` hasn't been called. */
78
+ export function send_event(
79
+ eventType: string,
80
+ data: Record<string, unknown>,
81
+ ): Promise<void> | undefined;
82
+
83
+ /**
84
+ * Attaches weflayr metadata tags to any SDK call options object.
85
+ *
86
+ * `__weflayr_tags` is stripped by the instrumented proxy before the real provider call
87
+ * is made, so it never reaches the underlying SDK. At runtime this is the identity
88
+ * function — the generic signature is its only purpose.
89
+ *
90
+ * @example
91
+ * await openai.chat.completions.create(flayred({
92
+ * model: 'gpt-4o-mini',
93
+ * messages: [...],
94
+ * __weflayr_tags: { feature: 'haiku', customer_id: '42' },
95
+ * }));
96
+ */
97
+ export function flayred<T>(options: T & { __weflayr_tags?: Record<string, string | number | boolean> }): T;
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "weflayr",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Weflayr Node.js SDK — instrument any LLM client via JS Proxy",
5
5
  "main": "src/index.js",
6
+ "types": "index.d.ts",
6
7
  "scripts": {
7
- "test": "node --test tests/"
8
+ "test": "node --test tests/index.test.js"
8
9
  },
9
10
  "keywords": ["llm", "observability", "weflayr"],
10
11
  "author": "Weflayr <contact@weflayr.com>",
@@ -12,7 +13,5 @@
12
13
  "engines": {
13
14
  "node": ">=18.0.0"
14
15
  },
15
- "dependencies": {
16
- "dotenv": "^16.0.0"
17
- }
16
+ "dependencies": {}
18
17
  }
package/src/index.js CHANGED
@@ -1,29 +1,35 @@
1
1
  'use strict';
2
2
 
3
- require('dotenv').config();
4
-
5
- const { configure, weflayr_instrument } = require('./instrument');
3
+ const { configure, weflayr_instrument, send_event } = require('./instrument');
6
4
 
7
5
  /**
8
- *
6
+ *
9
7
  * @param {*} settings: settings for weflayr
10
- * @param {*} on/off: easily enable or disable weflayr
11
- * @returns
8
+ * @returns
12
9
  */
13
- function weflayr_setup(settings, { enabled = true } = {}) {
14
- if (!enabled) return;
10
+ function weflayr_setup(settings) {
11
+ if (settings.enabled === false) return;
15
12
 
16
- const intakeUrl = process.env.WEFLAYR_INTAKE_URL;
17
- const clientId = process.env.WEFLAYR_CLIENT_ID;
18
- const clientSecret = process.env.WEFLAYR_CLIENT_SECRET;
13
+ const { intake_url, client_id, client_secret } = settings;
19
14
 
20
- if (!intakeUrl || !clientId || !clientSecret) {
15
+ if (!intake_url || !client_id || !client_secret) {
21
16
  throw new Error(
22
- 'Weflayr: WEFLAYR_INTAKE_URL, WEFLAYR_CLIENT_ID and WEFLAYR_CLIENT_SECRET must be set'
17
+ 'Weflayr: intake_url, client_id and client_secret must be set in settings'
23
18
  );
24
19
  }
25
20
 
26
- configure(intakeUrl, clientId, clientSecret, settings);
21
+ configure(intake_url, client_id, client_secret, settings);
22
+ }
23
+
24
+ /**
25
+ * Attaches weflayr metadata tags to any SDK call options object.
26
+ *
27
+ * @template T
28
+ * @param {T & { __weflayr_tags?: Record<string, string | number | boolean> }} options
29
+ * @returns {T}
30
+ */
31
+ function flayred(options) {
32
+ return options;
27
33
  }
28
34
 
29
- module.exports = { weflayr_setup, weflayr_instrument };
35
+ module.exports = { weflayr_setup, weflayr_instrument, send_event, flayred };
package/src/instrument.js CHANGED
@@ -7,14 +7,19 @@ const { randomUUID } = require('crypto');
7
7
  let _config = null;
8
8
 
9
9
  function configure(intakeUrl, clientId, clientSecret, settings) {
10
- _config = { intakeUrl, clientId, clientSecret, settings };
10
+ if (settings.ignore_fields && settings.allow_fields) {
11
+ console.warn('[weflayr] both ignore_fields and allow_fields are set - no events will be sent to the intake API');
12
+ _config = { intakeUrl, clientId, clientSecret, settings, _blocked: true };
13
+ return;
14
+ }
15
+ _config = { intakeUrl, clientId, clientSecret, settings, _blocked: false };
11
16
  }
12
17
 
13
18
  function weflayr_instrument(target) {
14
19
  if (!_config) return target;
15
20
 
16
21
  if (typeof target === 'function') {
17
- const methodConfig = _config.settings.methods.find(m => m.call === target.name);
22
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === target.name);
18
23
  if (!methodConfig) return target;
19
24
  return _wrapFn(target, target.name, methodConfig);
20
25
  }
@@ -35,7 +40,7 @@ function _makeProxy(target, pathPrefix) {
35
40
  const fullPath = pathPrefix ? `${pathPrefix}.${prop}` : prop;
36
41
 
37
42
  if (typeof value === 'function') {
38
- const methodConfig = _config.settings.methods.find(m => m.call === fullPath);
43
+ const methodConfig = (_config.settings.methods || []).find(m => m.call === fullPath);
39
44
  if (methodConfig) {
40
45
  return _wrapFn(value.bind(obj), fullPath, methodConfig);
41
46
  }
@@ -57,16 +62,19 @@ function _wrapFn(fn, methodName, methodConfig) {
57
62
  const startTime = Date.now();
58
63
  const { settings } = _config;
59
64
  const eventId = randomUUID();
65
+ // For multi-arg methods (e.g. convert(voiceId, request)), use the last arg as the
66
+ // request object so middleware and content policy see the options, not the id.
67
+ const requestArgs = cleanArgs[cleanArgs.length - 1];
60
68
 
61
69
  if (settings.event_mode !== 'light') {
62
70
  const beforeMiddlewareData = methodConfig.middleware
63
- ? methodConfig.middleware(cleanArgs[0], null) || {}
71
+ ? methodConfig.middleware(requestArgs, null) || {}
64
72
  : {};
65
73
  _sendEvent('before', {
66
74
  event_id: eventId,
67
75
  method: methodName,
68
76
  tags,
69
- args: _applyContentPolicy(cleanArgs[0], settings),
77
+ args: _applyContentPolicy(requestArgs, settings),
70
78
  ...beforeMiddlewareData,
71
79
  });
72
80
  }
@@ -76,18 +84,18 @@ function _wrapFn(fn, methodName, methodConfig) {
76
84
 
77
85
  if (_isAsyncIterable(result)) {
78
86
  _sendEvent('stream_start', { event_id: eventId, method: methodName, tags });
79
- return _wrapStream(result, methodName, tags, cleanArgs[0], settings, startTime, methodConfig, eventId);
87
+ return _wrapStream(result, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId);
80
88
  }
81
89
 
82
90
  const middlewareData = methodConfig.middleware
83
- ? methodConfig.middleware(cleanArgs[0], result) || {}
91
+ ? methodConfig.middleware(requestArgs, result) || {}
84
92
  : {};
85
93
 
86
94
  _sendEvent('after', {
87
95
  event_id: eventId,
88
96
  method: methodName,
89
97
  tags,
90
- args: _applyContentPolicy(cleanArgs[0], settings),
98
+ args: _applyContentPolicy(requestArgs, settings),
91
99
  response: _applyContentPolicy(_toPlain(result), settings),
92
100
  elapsed_ms: Date.now() - startTime,
93
101
  ...middlewareData,
@@ -95,13 +103,13 @@ function _wrapFn(fn, methodName, methodConfig) {
95
103
 
96
104
  return result;
97
105
  } catch (err) {
98
- _sendEvent('after', {
106
+ await _sendEvent('after', {
99
107
  event_id: eventId,
100
108
  method: methodName,
101
109
  tags,
102
- args: _applyContentPolicy(cleanArgs[0], settings),
103
- error: err.message,
104
- status_code: err.status,
110
+ args: _applyContentPolicy(requestArgs, settings),
111
+ error: err.message ?? 'unknown',
112
+ status_code: err.status ?? err.statusCode ?? 'unknown',
105
113
  elapsed_ms: Date.now() - startTime,
106
114
  });
107
115
  throw err;
@@ -111,16 +119,27 @@ function _wrapFn(fn, methodName, methodConfig) {
111
119
 
112
120
  async function* _wrapStream(stream, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId) {
113
121
  let lastChunk = null;
122
+ const accumulator = methodConfig.streamMiddleware ? methodConfig.streamMiddleware() : null;
114
123
 
115
124
  try {
116
125
  for await (const chunk of stream) {
117
126
  lastChunk = chunk;
127
+ if (accumulator && accumulator.onChunk(chunk)) {
128
+ _sendEvent('stream_pending', {
129
+ event_id: eventId,
130
+ method: methodName,
131
+ tags,
132
+ elapsed_ms: Date.now() - startTime,
133
+ ...accumulator.finalize(),
134
+ });
135
+ }
118
136
  yield chunk;
119
137
  }
120
138
 
121
- const middlewareData = methodConfig.middleware && lastChunk
122
- ? methodConfig.middleware(requestArgs, lastChunk) || {}
123
- : {};
139
+ const middlewareData = accumulator
140
+ ? accumulator.finalize()
141
+ : (methodConfig.middleware && lastChunk ? methodConfig.middleware(requestArgs, lastChunk) || {}
142
+ : {});
124
143
 
125
144
  _sendEvent('after', {
126
145
  event_id: eventId,
@@ -132,12 +151,13 @@ async function* _wrapStream(stream, methodName, tags, requestArgs, settings, sta
132
151
  ...middlewareData,
133
152
  });
134
153
  } catch (err) {
135
- _sendEvent('after', {
154
+ await _sendEvent('after', {
136
155
  event_id: eventId,
137
156
  method: methodName,
138
157
  tags,
139
158
  args: _applyContentPolicy(requestArgs, settings),
140
- error: err.message,
159
+ error: err.message ?? 'unknown',
160
+ status_code: err.status ?? err.statusCode ?? 'unknown',
141
161
  elapsed_ms: Date.now() - startTime,
142
162
  });
143
163
  throw err;
@@ -163,86 +183,54 @@ function _isAsyncIterable(value) {
163
183
 
164
184
  function _toPlain(value) {
165
185
  if (!value) return value;
186
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array || value instanceof ArrayBuffer) return null;
166
187
  if (typeof value.toJSON === 'function') return value.toJSON();
167
188
  return value;
168
189
  }
169
190
 
170
191
  function _applyContentPolicy(data, settings) {
171
192
  if (!data) return data;
172
-
193
+ const fn = settings.ignore_fields || settings.allow_fields;
194
+ if (!fn) return data;
173
195
  let clone;
174
196
  try {
175
197
  clone = JSON.parse(JSON.stringify(data));
176
198
  } catch {
177
199
  return data;
178
200
  }
179
-
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);
201
+ try {
202
+ return fn(clone) ?? clone;
203
+ } catch (err) {
204
+ console.warn('[weflayr] content policy function error:', err.message);
205
+ return clone;
186
206
  }
187
-
188
- return clone;
189
207
  }
190
208
 
191
- function _pickFields(obj, allowFields) {
192
- if (!obj || typeof obj !== 'object' || allowFields.length === 0) return obj;
209
+ const _debug = process.env.WEFLAYR_DEBUG === 'true';
193
210
 
194
- const result = {};
195
- for (const field of allowFields) {
196
- const arrayMatch = field.match(/^(\w+)\[\]\.(.+)$/);
197
- if (arrayMatch) {
198
- const [, arrayKey, rest] = arrayMatch;
199
- if (Array.isArray(obj[arrayKey])) {
200
- result[arrayKey] = obj[arrayKey].map(item => _pickFields(item, [rest]));
201
- }
202
- continue;
203
- }
204
-
205
- const dotIdx = field.indexOf('.');
206
- if (dotIdx === -1) {
207
- if (field in obj) result[field] = obj[field];
208
- } else {
209
- const key = field.slice(0, dotIdx);
210
- const rest = field.slice(dotIdx + 1);
211
- if (key in obj) result[key] = _pickFields(obj[key], [rest]);
212
- }
211
+ function _sendEvent(eventType, data) {
212
+ if (_config._blocked) {
213
+ console.warn(`[weflayr] is blocked and can't send event, ensure your configuration is valid`);
214
+ return;
213
215
  }
214
- return result;
215
- }
216
-
217
- function _deleteField(obj, path) {
218
- if (!obj || typeof obj !== 'object') return;
216
+ const { intakeUrl, clientId, clientSecret } = _config;
217
+ const endpoint = `${intakeUrl.replace(/\/$/, '')}/${clientId}/`;
219
218
 
220
- const arrayMatch = path.match(/^(\w+)\[\]\.(.+)$/);
221
- if (arrayMatch) {
222
- const [, arrayKey, rest] = arrayMatch;
223
- if (Array.isArray(obj[arrayKey])) {
224
- obj[arrayKey].forEach(item => _deleteField(item, rest));
225
- }
219
+ let body;
220
+ try {
221
+ body = JSON.stringify({
222
+ event_type: eventType,
223
+ timestamp: new Date().toISOString(),
224
+ ...data,
225
+ });
226
+ } catch (err) {
227
+ console.warn(`[weflayr] could not serialize event "${eventType}":`, err.message);
226
228
  return;
227
229
  }
228
230
 
229
- const dotIdx = path.indexOf('.');
230
- if (dotIdx === -1) {
231
- delete obj[path];
232
- } else {
233
- _deleteField(obj[path.slice(0, dotIdx)], path.slice(dotIdx + 1));
231
+ if (_debug) {
232
+ console.debug(`[weflayr] ${eventType}`, JSON.parse(body));
234
233
  }
235
- }
236
-
237
- function _sendEvent(eventType, data) {
238
- const { intakeUrl, clientId, clientSecret } = _config;
239
- const endpoint = `${intakeUrl.replace(/\/$/, '')}/${clientId}/`;
240
-
241
- const body = JSON.stringify({
242
- event_type: eventType,
243
- timestamp: new Date().toISOString(),
244
- ...data,
245
- });
246
234
 
247
235
  let parsed;
248
236
  try {
@@ -252,30 +240,42 @@ function _sendEvent(eventType, data) {
252
240
  }
253
241
 
254
242
  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
- });
243
+ return new Promise((resolve) => {
244
+ const req = lib.request({
245
+ hostname: parsed.hostname,
246
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
247
+ path: parsed.pathname,
248
+ method: 'POST',
249
+ headers: {
250
+ 'Content-Type': 'application/json',
251
+ 'Content-Length': Buffer.byteLength(body),
252
+ 'Authorization': `Bearer ${clientSecret}`,
253
+ },
254
+ }, res => {
255
+ res.resume();
256
+ if (_debug) {
257
+ console.debug(`[weflayr] ← ${eventType} status ${res.statusCode}`);
258
+ }
259
+ if (res.statusCode >= 400) {
260
+ console.warn(`[weflayr] intake returned ${res.statusCode} for event "${eventType}"`);
261
+ }
262
+ resolve();
263
+ });
271
264
 
272
- req.on('error', (err) => {
273
- console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
265
+ req.on('error', (err) => {
266
+ console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
267
+ resolve();
268
+ });
269
+
270
+ req.write(body);
271
+ req.end();
274
272
  });
273
+ }
275
274
 
276
- req.write(body);
277
-
278
- req.end();
275
+ // Send an arbitrary event to the intake. Requires configure() to have been called first.
276
+ function send_event(eventType, data) {
277
+ if (!_config) return;
278
+ return _sendEvent(eventType, data);
279
279
  }
280
280
 
281
- module.exports = { configure, weflayr_instrument };
281
+ 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,188 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('assert/strict');
5
+
6
+ const TEST_CREDS = {
7
+ intake_url: 'http://localhost:19999', // nothing listening — fire-and-forget is fine
8
+ client_id: 'test-client-id',
9
+ client_secret: 'test-secret',
10
+ };
11
+
12
+ // Reset module cache between tests so _config doesn't leak
13
+ function freshSDK() {
14
+ delete require.cache[require.resolve('../src/instrument')];
15
+ delete require.cache[require.resolve('../src/index')];
16
+ return require('../src/index');
17
+ }
18
+
19
+ // Minimal fake client: one async method at a nested path
20
+ function fakeClient() {
21
+ return {
22
+ chat: {
23
+ completions: {
24
+ create: async (args) => ({ id: 'res-1', model: args.model, choices: [] }),
25
+ },
26
+ },
27
+ };
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Content policy — function-based middleware
32
+ // ---------------------------------------------------------------------------
33
+
34
+ test('ignore_fields function filters event data', async () => {
35
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
36
+
37
+ weflayr_setup({
38
+ ...TEST_CREDS,
39
+ event_mode: 'default',
40
+ ignore_fields: (data) => { delete data.messages; return data; },
41
+ methods: [{ call: 'chat.completions.create' }],
42
+ });
43
+
44
+ const client = weflayr_instrument(fakeClient());
45
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
46
+
47
+ assert.equal(result.id, 'res-1');
48
+ });
49
+
50
+ test('allow_fields function filters event data', async () => {
51
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
52
+
53
+ weflayr_setup({
54
+ ...TEST_CREDS,
55
+ event_mode: 'default',
56
+ allow_fields: (data) => ({ model: data.model }),
57
+ methods: [{ call: 'chat.completions.create' }],
58
+ });
59
+
60
+ const client = weflayr_instrument(fakeClient());
61
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
62
+
63
+ assert.equal(result.id, 'res-1');
64
+ });
65
+
66
+ test('both ignore_fields and allow_fields set — no events sent, function still works', async () => {
67
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
68
+
69
+ weflayr_setup({
70
+ ...TEST_CREDS,
71
+ event_mode: 'default',
72
+ ignore_fields: (data) => data,
73
+ allow_fields: (data) => data,
74
+ methods: [{ call: 'chat.completions.create' }],
75
+ });
76
+
77
+ const client = weflayr_instrument(fakeClient());
78
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
79
+
80
+ assert.equal(result.id, 'res-1');
81
+ });
82
+
83
+ test('no content policy — pass-through, function still works', async () => {
84
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
85
+
86
+ weflayr_setup({
87
+ ...TEST_CREDS,
88
+ event_mode: 'default',
89
+ methods: [{ call: 'chat.completions.create' }],
90
+ });
91
+
92
+ const client = weflayr_instrument(fakeClient());
93
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
94
+
95
+ assert.equal(result.id, 'res-1');
96
+ });
97
+
98
+ test('ignore_fields does not mutate actual call args', async () => {
99
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
100
+
101
+ let capturedArgs;
102
+ const client = {
103
+ chat: {
104
+ completions: {
105
+ create: async (args) => { capturedArgs = args; return { id: 'res-1' }; },
106
+ },
107
+ },
108
+ };
109
+
110
+ weflayr_setup({
111
+ ...TEST_CREDS,
112
+ event_mode: 'default',
113
+ ignore_fields: (data) => { delete data.messages; return data; },
114
+ methods: [{ call: 'chat.completions.create' }],
115
+ });
116
+
117
+ const instrumented = weflayr_instrument(client);
118
+ await instrumented.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hello' }] });
119
+
120
+ assert.ok(Array.isArray(capturedArgs.messages));
121
+ assert.equal(capturedArgs.messages[0].content, 'hello');
122
+ });
123
+
124
+ test('missing methods does not crash on object instrumentation', async () => {
125
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
126
+
127
+ weflayr_setup({
128
+ ...TEST_CREDS,
129
+ event_mode: 'default',
130
+ // methods intentionally omitted
131
+ });
132
+
133
+ const client = weflayr_instrument(fakeClient());
134
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
135
+
136
+ assert.equal(result.id, 'res-1');
137
+ });
138
+
139
+ test('missing methods does not crash on function instrumentation', async () => {
140
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
141
+
142
+ weflayr_setup({
143
+ ...TEST_CREDS,
144
+ event_mode: 'default',
145
+ // methods intentionally omitted
146
+ });
147
+
148
+ async function myFn(x) { return x * 2; }
149
+ const instrumented = weflayr_instrument(myFn);
150
+
151
+ assert.equal(await instrumented(5), 10);
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // enabled: false — target passes through unchanged, calls still work
156
+ // ---------------------------------------------------------------------------
157
+
158
+ test('enabled:false returns the original object untouched', () => {
159
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
160
+
161
+ weflayr_setup({ methods: [{ call: 'chat.completions.create' }] , enabled: false });
162
+
163
+ const client = fakeClient();
164
+ const result = weflayr_instrument(client);
165
+
166
+ assert.strictEqual(result, client);
167
+ });
168
+
169
+ test('enabled:false returns the original function untouched', () => {
170
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
171
+
172
+ async function myFn(x) { return x * 2; }
173
+ weflayr_setup({ methods: [{ call: 'myFn' }] , enabled: false });
174
+
175
+ assert.strictEqual(weflayr_instrument(myFn), myFn);
176
+ });
177
+
178
+ test('enabled:false — original function still executes correctly', async () => {
179
+ const { weflayr_setup, weflayr_instrument } = freshSDK();
180
+
181
+ weflayr_setup({ methods: [{ call: 'chat.completions.create' }] , enabled: false });
182
+
183
+ const client = weflayr_instrument(fakeClient());
184
+ const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
185
+
186
+ assert.equal(result.id, 'res-1');
187
+ });
188
+