weflayr 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.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.1",
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.2.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/"
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 } = 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 };
@@ -0,0 +1,281 @@
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
+
61
+ if (settings.event_mode !== 'light') {
62
+ const beforeMiddlewareData = methodConfig.middleware
63
+ ? methodConfig.middleware(cleanArgs[0], null) || {}
64
+ : {};
65
+ _sendEvent('before', {
66
+ event_id: eventId,
67
+ method: methodName,
68
+ tags,
69
+ args: _applyContentPolicy(cleanArgs[0], settings),
70
+ ...beforeMiddlewareData,
71
+ });
72
+ }
73
+
74
+ try {
75
+ const result = await fn(...cleanArgs);
76
+
77
+ if (_isAsyncIterable(result)) {
78
+ _sendEvent('stream_start', { event_id: eventId, method: methodName, tags });
79
+ return _wrapStream(result, methodName, tags, cleanArgs[0], settings, startTime, methodConfig, eventId);
80
+ }
81
+
82
+ const middlewareData = methodConfig.middleware
83
+ ? methodConfig.middleware(cleanArgs[0], result) || {}
84
+ : {};
85
+
86
+ _sendEvent('after', {
87
+ event_id: eventId,
88
+ method: methodName,
89
+ tags,
90
+ args: _applyContentPolicy(cleanArgs[0], settings),
91
+ response: _applyContentPolicy(_toPlain(result), settings),
92
+ elapsed_ms: Date.now() - startTime,
93
+ ...middlewareData,
94
+ });
95
+
96
+ return result;
97
+ } catch (err) {
98
+ _sendEvent('after', {
99
+ event_id: eventId,
100
+ method: methodName,
101
+ tags,
102
+ args: _applyContentPolicy(cleanArgs[0], settings),
103
+ error: err.message,
104
+ status_code: err.status,
105
+ elapsed_ms: Date.now() - startTime,
106
+ });
107
+ throw err;
108
+ }
109
+ };
110
+ }
111
+
112
+ async function* _wrapStream(stream, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId) {
113
+ let lastChunk = null;
114
+
115
+ try {
116
+ for await (const chunk of stream) {
117
+ lastChunk = chunk;
118
+ yield chunk;
119
+ }
120
+
121
+ const middlewareData = methodConfig.middleware && lastChunk
122
+ ? methodConfig.middleware(requestArgs, lastChunk) || {}
123
+ : {};
124
+
125
+ _sendEvent('after', {
126
+ event_id: eventId,
127
+ method: methodName,
128
+ tags,
129
+ args: _applyContentPolicy(requestArgs, settings),
130
+ response: _applyContentPolicy(_toPlain(lastChunk), settings),
131
+ elapsed_ms: Date.now() - startTime,
132
+ ...middlewareData,
133
+ });
134
+ } catch (err) {
135
+ _sendEvent('after', {
136
+ event_id: eventId,
137
+ method: methodName,
138
+ tags,
139
+ args: _applyContentPolicy(requestArgs, settings),
140
+ error: err.message,
141
+ elapsed_ms: Date.now() - startTime,
142
+ });
143
+ throw err;
144
+ }
145
+ }
146
+
147
+ function _extractTags(args) {
148
+ let tags = {};
149
+ const cleanArgs = args.map(arg => {
150
+ if (arg && typeof arg === 'object' && !Array.isArray(arg) && '__weflayr_tags' in arg) {
151
+ const { __weflayr_tags, ...rest } = arg;
152
+ tags = __weflayr_tags || {};
153
+ return rest;
154
+ }
155
+ return arg;
156
+ });
157
+ return { tags, cleanArgs };
158
+ }
159
+
160
+ function _isAsyncIterable(value) {
161
+ return value != null && typeof value[Symbol.asyncIterator] === 'function';
162
+ }
163
+
164
+ function _toPlain(value) {
165
+ if (!value) return value;
166
+ if (typeof value.toJSON === 'function') return value.toJSON();
167
+ return value;
168
+ }
169
+
170
+ function _applyContentPolicy(data, settings) {
171
+ if (!data) return data;
172
+
173
+ let clone;
174
+ try {
175
+ clone = JSON.parse(JSON.stringify(data));
176
+ } catch {
177
+ return data;
178
+ }
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);
186
+ }
187
+
188
+ return clone;
189
+ }
190
+
191
+ function _pickFields(obj, allowFields) {
192
+ if (!obj || typeof obj !== 'object' || allowFields.length === 0) return obj;
193
+
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
+ }
213
+ }
214
+ return result;
215
+ }
216
+
217
+ function _deleteField(obj, path) {
218
+ if (!obj || typeof obj !== 'object') return;
219
+
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
+ }
226
+ return;
227
+ }
228
+
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));
234
+ }
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
+
247
+ let parsed;
248
+ try {
249
+ parsed = new URL(endpoint);
250
+ } catch {
251
+ return;
252
+ }
253
+
254
+ 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
+ });
271
+
272
+ req.on('error', (err) => {
273
+ console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
274
+ });
275
+
276
+ req.write(body);
277
+
278
+ req.end();
279
+ }
280
+
281
+ module.exports = { configure, weflayr_instrument };
package/src/openai.js DELETED
@@ -1,114 +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
- // 1. Patch all explicitly defined routes with their precise extractors.
108
- for (const route of ROUTES) {
109
- route.set(client, makeWrapper(route.get(client), route, tracer));
110
- }
111
-
112
- // 2. Wrap the whole client in a Proxy that handles anything not in ROUTES.
113
- return deepFallbackProxy(client, tracer, PATCHED_PATHS);
114
- }
package/src/weflayr.js DELETED
@@ -1,228 +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) {
121
- return async function ({ tags: callTags, ...params }) {
122
- const tags = { ...GLOBAL_TAGS, ...callTags };
123
- const span = tracer.startSpan(route.name, {
124
- attributes: {
125
- "weflayr.event_id": randomUUID(),
126
- "weflayr.before": JSON.stringify(route.before(params)),
127
- "weflayr.tags": JSON.stringify(tags),
128
- },
129
- });
130
-
131
- try {
132
- const response = await original(params);
133
- span.setAttribute("weflayr.after", JSON.stringify(route.after(response)));
134
- span.setStatus({ code: SpanStatusCode.OK });
135
- return response;
136
- } catch (err) {
137
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
138
- span.setAttribute("error.type", err.constructor.name);
139
- span.setAttribute("error.message", err.message);
140
- throw err;
141
- } finally {
142
- span.end();
143
- }
144
- };
145
- }
146
-
147
- export function makeFallbackWrapper(fn, target, name, tracer) {
148
- return function (...args) {
149
- let callArgs = args;
150
- let tags = GLOBAL_TAGS;
151
-
152
- // If the first argument is a plain params object, extract `tags` from it.
153
- if (args[0] !== null && typeof args[0] === "object" && !Array.isArray(args[0])) {
154
- const { tags: callTags, ...rest } = args[0];
155
- callArgs = [rest, ...args.slice(1)];
156
- tags = { ...GLOBAL_TAGS, ...callTags };
157
- }
158
-
159
- const before = { model: callArgs[0]?.model };
160
- const span = tracer.startSpan(name, {
161
- attributes: {
162
- "weflayr.event_id": randomUUID(),
163
- "weflayr.before": JSON.stringify(before),
164
- "weflayr.tags": JSON.stringify(tags),
165
- },
166
- });
167
-
168
- let result;
169
- try {
170
- result = fn.apply(target, callArgs);
171
- } catch (err) {
172
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
173
- span.setAttribute("error.type", err.constructor.name);
174
- span.setAttribute("error.message", err.message);
175
- span.end();
176
- throw err;
177
- }
178
-
179
- // Handle both sync and async return values.
180
- if (result && typeof result.then === "function") {
181
- return result.then(
182
- (response) => {
183
- span.setAttribute("weflayr.after", "{}");
184
- span.setStatus({ code: SpanStatusCode.OK });
185
- span.end();
186
- return response;
187
- },
188
- (err) => {
189
- span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
190
- span.setAttribute("error.type", err.constructor.name);
191
- span.setAttribute("error.message", err.message);
192
- span.end();
193
- throw err;
194
- }
195
- );
196
- }
197
-
198
- span.setAttribute("weflayr.after", "{}");
199
- span.setStatus({ code: SpanStatusCode.OK });
200
- span.end();
201
- return result;
202
- };
203
- }
204
-
205
- // Recursively wraps every function on `obj` that is not already covered by patchedPaths.
206
- // `path` tracks the dotted property path (e.g. "images.generate").
207
- export function deepFallbackProxy(obj, tracer, patchedPaths, path = "") {
208
- return new Proxy(obj, {
209
- get(target, prop) {
210
- if (typeof prop !== "string") return Reflect.get(target, prop);
211
-
212
- const val = Reflect.get(target, prop);
213
- const fullPath = path ? `${path}.${prop}` : prop;
214
-
215
- if (typeof val === "function") {
216
- // Already patched by explicit routes — return the existing wrapper as-is.
217
- if (patchedPaths.has(fullPath)) return val;
218
- return makeFallbackWrapper(val, target, fullPath, tracer);
219
- }
220
-
221
- if (val !== null && typeof val === "object") {
222
- return deepFallbackProxy(val, tracer, patchedPaths, fullPath);
223
- }
224
-
225
- return val;
226
- },
227
- });
228
- }