weflayr 0.16.0 → 0.20.2
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/.prettierrc.json +7 -0
- package/LICENSE-MIT +21 -0
- package/NOTICE +12 -0
- package/eslint.config.mjs +36 -0
- package/index.d.ts +125 -103
- package/package.json +19 -3
- package/src/auto-instrument.js +114 -0
- package/src/index.js +12 -34
- package/src/logger.js +8 -0
- package/src/otel/exporter.js +113 -0
- package/src/otel/openai-streaming-usage.js +60 -0
- package/src/otel/propagation.js +119 -0
- package/src/otel/span-filter.js +73 -0
- package/src/otel/span-processor.js +123 -0
- package/src/setup.js +168 -0
- package/tests/index.test.js +569 -980
- package/.env.example +0 -3
- package/src/instrument.js +0 -324
- package/src/providers/anthropic-ai-sdk.js +0 -63
- package/src/providers/bedrock-runtime.d.ts +0 -6
- package/src/providers/bedrock-runtime.js +0 -28
- package/src/vercel-ai-middleware.js +0 -124
package/tests/index.test.js
CHANGED
|
@@ -1,1102 +1,691 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
request: { body: '{"messages":[{"role":"user","content":"Hello"}]}' },
|
|
40
|
-
response: {
|
|
41
|
-
id: 'fake-res',
|
|
42
|
-
modelId: 'gpt-4o-mini',
|
|
43
|
-
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Hello.' }] }],
|
|
44
|
-
body: { choices: [{ message: { role: 'assistant', content: 'Hello.' } }] },
|
|
45
|
-
},
|
|
46
|
-
steps: [{ text: 'Hello.', request: { body: '...' }, finishReason: 'stop' }],
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
|
-
// stream() returns a StreamTextResult-like object (NOT directly iterable).
|
|
50
|
-
// usage and response are DelayedPromise wrappers matching real Mastra behaviour.
|
|
51
|
-
Agent.prototype.stream = function(_messages, _options) {
|
|
52
|
-
return {
|
|
53
|
-
textStream: (async function* () {
|
|
54
|
-
yield 'Hello'; yield ' Mastra'; yield ' stream.';
|
|
55
|
-
})(),
|
|
56
|
-
usage: { status: { type: 'resolved', value: { promptTokens: 10, completionTokens: 15, totalTokens: 25 } } },
|
|
57
|
-
response: { status: { type: 'resolved', value: { modelId: 'gpt-4o-mini', id: 'fake-stream-res' } } },
|
|
58
|
-
};
|
|
59
|
-
};
|
|
60
|
-
return { Agent };
|
|
61
|
-
}
|
|
62
|
-
if (request === '@ai-sdk/openai') {
|
|
63
|
-
return { openai: (modelId) => ({ _provider: 'openai', modelId }) };
|
|
64
|
-
}
|
|
65
|
-
if (request === '@ai-sdk/openai-compatible') {
|
|
66
|
-
function createOpenAICompatible({ name }) {
|
|
67
|
-
return function(modelId) {
|
|
68
|
-
return {
|
|
69
|
-
specificationVersion: 'v1',
|
|
70
|
-
provider: name,
|
|
71
|
-
modelId,
|
|
72
|
-
defaultObjectGenerationMode: undefined,
|
|
73
|
-
doGenerate: async () => ({
|
|
74
|
-
text: 'Hello!',
|
|
75
|
-
usage: { promptTokens: 10, completionTokens: 5 },
|
|
76
|
-
finishReason: 'stop',
|
|
77
|
-
rawCall: { rawPrompt: [], rawSettings: {} },
|
|
78
|
-
rawResponse: { headers: {} },
|
|
79
|
-
warnings: [],
|
|
80
|
-
}),
|
|
81
|
-
doStream: async () => ({
|
|
82
|
-
stream: new ReadableStream({
|
|
83
|
-
start(ctrl) {
|
|
84
|
-
ctrl.enqueue({ type: 'text-delta', textDelta: 'Hello!' });
|
|
85
|
-
ctrl.enqueue({ type: 'finish', finishReason: 'stop', usage: { promptTokens: 10, completionTokens: 5 } });
|
|
86
|
-
ctrl.close();
|
|
87
|
-
},
|
|
88
|
-
}),
|
|
89
|
-
rawCall: { rawPrompt: [], rawSettings: {} },
|
|
90
|
-
rawResponse: { headers: {} },
|
|
91
|
-
warnings: [],
|
|
92
|
-
}),
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
return { createOpenAICompatible };
|
|
97
|
-
}
|
|
98
|
-
if (request === 'ai') {
|
|
99
|
-
function wrapLanguageModel({ model, middleware }) {
|
|
100
|
-
return {
|
|
101
|
-
modelId: model.modelId,
|
|
102
|
-
doGenerate: async (params) =>
|
|
103
|
-
middleware.wrapGenerate({ doGenerate: () => model.doGenerate(params), params, model }),
|
|
104
|
-
doStream: async (params) =>
|
|
105
|
-
middleware.wrapStream({ doStream: () => model.doStream(params), params, model }),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
async function generateText({ model, prompt }) {
|
|
109
|
-
const params = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] };
|
|
110
|
-
return model.doGenerate(params);
|
|
111
|
-
}
|
|
112
|
-
function streamText({ model, prompt }) {
|
|
113
|
-
const params = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }] };
|
|
114
|
-
const streamPromise = model.doStream(params);
|
|
115
|
-
return {
|
|
116
|
-
get textStream() {
|
|
117
|
-
return (async function* () {
|
|
118
|
-
const { stream } = await streamPromise;
|
|
119
|
-
const reader = stream.getReader();
|
|
120
|
-
while (true) {
|
|
121
|
-
const { done, value } = await reader.read();
|
|
122
|
-
if (done) break;
|
|
123
|
-
if (value && value.type === 'text-delta') yield value.textDelta;
|
|
124
|
-
}
|
|
125
|
-
})();
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return { wrapLanguageModel, generateText, streamText };
|
|
130
|
-
}
|
|
131
|
-
if (request === 'weflayr') {
|
|
132
|
-
return _origLoad.call(this, path.resolve(__dirname, '../src/index.js'), parent, isMain);
|
|
133
|
-
}
|
|
134
|
-
return _origLoad.call(this, request, parent, isMain);
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const Module = require('node:module');
|
|
6
|
+
|
|
7
|
+
const { trace, context: otelContext } = require('@opentelemetry/api');
|
|
8
|
+
const { BasicTracerProvider } = require('@opentelemetry/sdk-trace-base');
|
|
9
|
+
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
|
|
10
|
+
const { AsyncHooksContextManager } = require('@opentelemetry/context-async-hooks');
|
|
11
|
+
|
|
12
|
+
const weflayr = require('../src');
|
|
13
|
+
const { _state } = require('../src/setup');
|
|
14
|
+
const { isDefaultExportSpan } = require('../src/otel/span-filter');
|
|
15
|
+
const { WeflayrSpanProcessor } = require('../src/otel/span-processor');
|
|
16
|
+
const { buildExporter, _exportWithRetry } = require('../src/otel/exporter');
|
|
17
|
+
const { patchOpenAIStreamingUsage } = require('../src/otel/openai-streaming-usage');
|
|
18
|
+
|
|
19
|
+
const contextManager = new AsyncHooksContextManager();
|
|
20
|
+
contextManager.enable();
|
|
21
|
+
otelContext.setGlobalContextManager(contextManager);
|
|
22
|
+
|
|
23
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeExporterStub() {
|
|
26
|
+
const exported = [];
|
|
27
|
+
return {
|
|
28
|
+
exported,
|
|
29
|
+
export(spans, cb) {
|
|
30
|
+
exported.push(...spans);
|
|
31
|
+
cb({ code: 0 });
|
|
32
|
+
},
|
|
33
|
+
shutdown() {
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
},
|
|
36
|
+
forceFlush() {
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
},
|
|
135
39
|
};
|
|
136
40
|
}
|
|
137
41
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
delete require.cache[require.resolve('../src/instrument')];
|
|
150
|
-
delete require.cache[require.resolve('../src/vercel-ai-middleware')];
|
|
151
|
-
delete require.cache[require.resolve('../src/index')];
|
|
152
|
-
return require('../src/index');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Minimal fake client: one async method at a nested path
|
|
156
|
-
function fakeClient() {
|
|
42
|
+
function makeFakeSpan({
|
|
43
|
+
attributes = {},
|
|
44
|
+
instrumentationScope = { name: 'opentelemetry.instrumentation.openai' },
|
|
45
|
+
name = 'openai.chat',
|
|
46
|
+
} = {}) {
|
|
47
|
+
const attrs = { ...attributes };
|
|
48
|
+
const ctx = {
|
|
49
|
+
traceId: '11111111111111111111111111111111',
|
|
50
|
+
spanId: '2222222222222222',
|
|
51
|
+
traceFlags: 1,
|
|
52
|
+
};
|
|
157
53
|
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
54
|
+
name,
|
|
55
|
+
attributes: attrs,
|
|
56
|
+
instrumentationScope,
|
|
57
|
+
instrumentationLibrary: instrumentationScope,
|
|
58
|
+
resource: { attributes: {} },
|
|
59
|
+
kind: 0,
|
|
60
|
+
status: { code: 0 },
|
|
61
|
+
startTime: [0, 0],
|
|
62
|
+
endTime: [0, 0],
|
|
63
|
+
duration: [0, 0],
|
|
64
|
+
events: [],
|
|
65
|
+
links: [],
|
|
66
|
+
droppedAttributesCount: 0,
|
|
67
|
+
droppedEventsCount: 0,
|
|
68
|
+
droppedLinksCount: 0,
|
|
69
|
+
ended: true,
|
|
70
|
+
spanContext: () => ctx,
|
|
71
|
+
setAttribute(k, v) {
|
|
72
|
+
attrs[k] = v;
|
|
73
|
+
},
|
|
74
|
+
setAttributes(obj) {
|
|
75
|
+
Object.assign(attrs, obj);
|
|
162
76
|
},
|
|
77
|
+
isRecording: () => true,
|
|
163
78
|
};
|
|
164
79
|
}
|
|
165
80
|
|
|
166
|
-
//
|
|
167
|
-
// Content policy — function-based middleware
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
81
|
+
// ── setup ─────────────────────────────────────────────────────────────────────
|
|
169
82
|
|
|
170
|
-
test('
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const client = weflayr_instrument(fakeClient());
|
|
181
|
-
const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
182
|
-
|
|
183
|
-
assert.equal(result.id, 'res-1');
|
|
83
|
+
test('setup throws without apiKey', () => {
|
|
84
|
+
const saved = process.env.WEFLAYR_API_KEY;
|
|
85
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
86
|
+
try {
|
|
87
|
+
assert.throws(() => weflayr.setup({}), /apiKey/);
|
|
88
|
+
} finally {
|
|
89
|
+
if (saved !== undefined) process.env.WEFLAYR_API_KEY = saved;
|
|
90
|
+
}
|
|
184
91
|
});
|
|
185
92
|
|
|
186
|
-
test('
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const client = weflayr_instrument(fakeClient());
|
|
197
|
-
const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
198
|
-
|
|
199
|
-
assert.equal(result.id, 'res-1');
|
|
93
|
+
test('setup picks up apiKey from WEFLAYR_API_KEY env var', () => {
|
|
94
|
+
trace.disable();
|
|
95
|
+
_state.processor = null;
|
|
96
|
+
process.env.WEFLAYR_API_KEY = 'env-key';
|
|
97
|
+
try {
|
|
98
|
+
weflayr.setup({});
|
|
99
|
+
assert.ok(_state.processor, 'processor must be installed using the env-var key');
|
|
100
|
+
} finally {
|
|
101
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
102
|
+
}
|
|
200
103
|
});
|
|
201
104
|
|
|
202
|
-
test('
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...TEST_CREDS,
|
|
207
|
-
event_mode: 'default',
|
|
208
|
-
ignore_fields: (data) => data,
|
|
209
|
-
allow_fields: (data) => data,
|
|
210
|
-
methods: [{ call: 'chat.completions.create' }],
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
const client = weflayr_instrument(fakeClient());
|
|
214
|
-
const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
215
|
-
|
|
216
|
-
assert.equal(result.id, 'res-1');
|
|
105
|
+
test('setup sets the content-capture env var when captureMessageContent=false', () => {
|
|
106
|
+
delete process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT;
|
|
107
|
+
weflayr.setup({ apiKey: 'test-key', captureMessageContent: false });
|
|
108
|
+
assert.equal(process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'false');
|
|
217
109
|
});
|
|
218
110
|
|
|
219
|
-
test('
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
...TEST_CREDS,
|
|
224
|
-
event_mode: 'default',
|
|
225
|
-
methods: [{ call: 'chat.completions.create' }],
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const client = weflayr_instrument(fakeClient());
|
|
229
|
-
const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
230
|
-
|
|
231
|
-
assert.equal(result.id, 'res-1');
|
|
111
|
+
test('setup with captureMessageContent=true sets env var to true', () => {
|
|
112
|
+
delete process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT;
|
|
113
|
+
weflayr.setup({ apiKey: 'test-key', captureMessageContent: true });
|
|
114
|
+
assert.equal(process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'true');
|
|
232
115
|
});
|
|
233
116
|
|
|
234
|
-
test('
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const client = {
|
|
239
|
-
chat: {
|
|
240
|
-
completions: {
|
|
241
|
-
create: async (args) => { capturedArgs = args; return { id: 'res-1' }; },
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
weflayr_setup({
|
|
247
|
-
...TEST_CREDS,
|
|
248
|
-
event_mode: 'default',
|
|
249
|
-
ignore_fields: (data) => { delete data.messages; return data; },
|
|
250
|
-
methods: [{ call: 'chat.completions.create' }],
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
const instrumented = weflayr_instrument(client);
|
|
254
|
-
await instrumented.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hello' }] });
|
|
255
|
-
|
|
256
|
-
assert.ok(Array.isArray(capturedArgs.messages));
|
|
257
|
-
assert.equal(capturedArgs.messages[0].content, 'hello');
|
|
117
|
+
test('setup without captureMessageContent leaves env var untouched', () => {
|
|
118
|
+
delete process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT;
|
|
119
|
+
weflayr.setup({ apiKey: 'test-key' });
|
|
120
|
+
assert.equal(process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, undefined);
|
|
258
121
|
});
|
|
259
122
|
|
|
260
|
-
test('
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
123
|
+
test('setup adopts a pre-existing TracerProvider', () => {
|
|
124
|
+
// Reset the global proxy and our _state so the test owns the registry.
|
|
125
|
+
trace.disable();
|
|
126
|
+
_state.processor = null;
|
|
127
|
+
|
|
128
|
+
// The user (or another OTel instrumentation) registers a provider first.
|
|
129
|
+
const foreign = new NodeTracerProvider({ spanProcessors: [] });
|
|
130
|
+
foreign.register();
|
|
131
|
+
|
|
132
|
+
weflayr.setup({ apiKey: 'test-key' });
|
|
133
|
+
|
|
134
|
+
// weflayr must have attached its processor to `foreign`, not created a new
|
|
135
|
+
// provider — otherwise provider.register() would have silently no-op'd and
|
|
136
|
+
// the LLM spans wouldn't reach our exporter.
|
|
137
|
+
const attached = foreign._activeSpanProcessor?._spanProcessors ?? [];
|
|
138
|
+
assert.ok(
|
|
139
|
+
attached.some((sp) => sp instanceof WeflayrSpanProcessor),
|
|
140
|
+
'WeflayrSpanProcessor must be attached to the existing TracerProvider'
|
|
141
|
+
);
|
|
142
|
+
assert.strictEqual(_state.tracerProvider, foreign);
|
|
273
143
|
});
|
|
274
144
|
|
|
275
|
-
test('
|
|
276
|
-
|
|
145
|
+
test('setup called twice replaces the prior processor', () => {
|
|
146
|
+
// Stacking would double-export every span and leak HTTP pools in
|
|
147
|
+
// long-running processes that reconfigure (e.g. API-key rotation).
|
|
148
|
+
trace.disable();
|
|
149
|
+
_state.processor = null;
|
|
150
|
+
|
|
151
|
+
weflayr.setup({ apiKey: 'k1' });
|
|
152
|
+
const firstProcessor = _state.processor;
|
|
153
|
+
let shutdownCalled = false;
|
|
154
|
+
const originalShutdown = firstProcessor.shutdown.bind(firstProcessor);
|
|
155
|
+
firstProcessor.shutdown = () => {
|
|
156
|
+
shutdownCalled = true;
|
|
157
|
+
return originalShutdown();
|
|
158
|
+
};
|
|
277
159
|
|
|
278
|
-
|
|
279
|
-
...TEST_CREDS,
|
|
280
|
-
event_mode: 'default',
|
|
281
|
-
// methods intentionally omitted
|
|
282
|
-
});
|
|
160
|
+
weflayr.setup({ apiKey: 'k2' });
|
|
283
161
|
|
|
284
|
-
|
|
285
|
-
|
|
162
|
+
assert.ok(_state.processor);
|
|
163
|
+
assert.notStrictEqual(_state.processor, firstProcessor);
|
|
164
|
+
assert.equal(shutdownCalled, true);
|
|
286
165
|
|
|
287
|
-
|
|
166
|
+
const tp = _state.tracerProvider;
|
|
167
|
+
const attached = tp._activeSpanProcessor?._spanProcessors ?? [];
|
|
168
|
+
assert.ok(!attached.includes(firstProcessor));
|
|
169
|
+
assert.ok(attached.includes(_state.processor));
|
|
288
170
|
});
|
|
289
171
|
|
|
290
|
-
//
|
|
291
|
-
// enabled: false — target passes through unchanged, calls still work
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
172
|
+
// ── buildExporter ─────────────────────────────────────────────────────────────
|
|
293
173
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
weflayr_setup({ methods: [{ call: 'chat.completions.create' }] , enabled: false });
|
|
298
|
-
|
|
299
|
-
const client = fakeClient();
|
|
300
|
-
const result = weflayr_instrument(client);
|
|
174
|
+
function _exporterParams(exporter) {
|
|
175
|
+
return exporter._delegate._transport._transport._parameters;
|
|
176
|
+
}
|
|
301
177
|
|
|
302
|
-
|
|
178
|
+
test('buildExporter sets the bearer header and base URL', () => {
|
|
179
|
+
const exporter = buildExporter({ baseUrl: 'https://api.weflayr.com', apiKey: 'sk-x' });
|
|
180
|
+
assert.equal(_exporterParams(exporter).url, 'https://api.weflayr.com/');
|
|
303
181
|
});
|
|
304
182
|
|
|
305
|
-
test('
|
|
306
|
-
const {
|
|
307
|
-
|
|
308
|
-
async function myFn(x) { return x * 2; }
|
|
309
|
-
weflayr_setup({ methods: [{ call: 'myFn' }] , enabled: false });
|
|
310
|
-
|
|
311
|
-
assert.strictEqual(weflayr_instrument(myFn), myFn);
|
|
183
|
+
test('buildExporter strips trailing slash on baseUrl', () => {
|
|
184
|
+
const exporter = buildExporter({ baseUrl: 'https://api.weflayr.com/', apiKey: 'k' });
|
|
185
|
+
assert.equal(_exporterParams(exporter).url, 'https://api.weflayr.com/');
|
|
312
186
|
});
|
|
313
187
|
|
|
314
|
-
|
|
315
|
-
const { weflayr_setup, weflayr_instrument } = freshSDK();
|
|
316
|
-
|
|
317
|
-
weflayr_setup({ methods: [{ call: 'chat.completions.create' }] , enabled: false });
|
|
318
|
-
|
|
319
|
-
const client = weflayr_instrument(fakeClient());
|
|
320
|
-
const result = await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
188
|
+
// ── span-filter ───────────────────────────────────────────────────────────────
|
|
321
189
|
|
|
322
|
-
|
|
190
|
+
test('isDefaultExportSpan accepts spans with gen_ai.* attributes', () => {
|
|
191
|
+
const span = makeFakeSpan({
|
|
192
|
+
attributes: { 'gen_ai.system': 'openai' },
|
|
193
|
+
instrumentationScope: { name: 'unknown' },
|
|
194
|
+
});
|
|
195
|
+
assert.equal(isDefaultExportSpan(span), true);
|
|
323
196
|
});
|
|
324
197
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// update_doc_snippets.py extracts between: // SNIPPET_START: <name> ... // SNIPPET_END: <name>
|
|
330
|
-
// ---------------------------------------------------------------------------
|
|
331
|
-
|
|
332
|
-
// Intercept http.request; returns array of parsed event JSON bodies.
|
|
333
|
-
async function captureHttp(fn) {
|
|
334
|
-
const http = require('http');
|
|
335
|
-
const sent = [];
|
|
336
|
-
const origRequest = http.request;
|
|
337
|
-
http.request = function(_opts, responseCb) {
|
|
338
|
-
const chunks = [];
|
|
339
|
-
const req = {
|
|
340
|
-
write: (d) => chunks.push(typeof d === 'string' ? d : d.toString()),
|
|
341
|
-
end: () => {
|
|
342
|
-
try { sent.push(JSON.parse(chunks.join(''))); } catch { /* skip malformed */ }
|
|
343
|
-
if (responseCb) responseCb({ resume: () => {}, on: () => {}, statusCode: 200 });
|
|
344
|
-
},
|
|
345
|
-
on: () => req,
|
|
346
|
-
};
|
|
347
|
-
return req;
|
|
348
|
-
};
|
|
349
|
-
try { await fn(); } finally { http.request = origRequest; }
|
|
350
|
-
return sent;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const SNIPPET_ENV = {
|
|
354
|
-
WEFLAYR_INTAKE_URL: TEST_CREDS.intake_url,
|
|
355
|
-
WEFLAYR_CLIENT_ID: TEST_CREDS.client_id,
|
|
356
|
-
WEFLAYR_CLIENT_SECRET: TEST_CREDS.client_secret,
|
|
357
|
-
OPENAI_API_KEY: 'sk-test',
|
|
358
|
-
};
|
|
359
|
-
function setSnippetEnv() { Object.assign(process.env, SNIPPET_ENV); }
|
|
360
|
-
|
|
361
|
-
test('snippet quickstart_setup: wraps client, sends before+after events', async () => {
|
|
362
|
-
freshSDK();
|
|
363
|
-
setSnippetEnv();
|
|
364
|
-
|
|
365
|
-
const sent = await captureHttp(async () => {
|
|
366
|
-
// SNIPPET_START: quickstart_setup
|
|
367
|
-
const OpenAI = require('openai');
|
|
368
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
369
|
-
|
|
370
|
-
weflayr_setup({
|
|
371
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
372
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
373
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
374
|
-
default_tags: {
|
|
375
|
-
app: 'my-app',
|
|
376
|
-
version: '1.0.0',
|
|
377
|
-
},
|
|
378
|
-
methods: [
|
|
379
|
-
{ call: 'chat.completions.create' },
|
|
380
|
-
],
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
|
|
384
|
-
// SNIPPET_END: quickstart_setup
|
|
385
|
-
|
|
386
|
-
assert.equal(typeof client.chat.completions.create, 'function');
|
|
387
|
-
await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
198
|
+
test('isDefaultExportSpan accepts spans from known instrumentation scopes', () => {
|
|
199
|
+
const span = makeFakeSpan({
|
|
200
|
+
attributes: {},
|
|
201
|
+
instrumentationScope: { name: 'opentelemetry.instrumentation.openai' },
|
|
388
202
|
});
|
|
389
|
-
|
|
390
|
-
assert.ok(sent.some(e => e.event_type === 'before'), 'before event sent');
|
|
391
|
-
assert.ok(sent.some(e => e.event_type === 'after'), 'after event sent');
|
|
392
|
-
assert.equal(sent[0].method, 'chat.completions.create');
|
|
393
|
-
assert.equal(sent[0].tags.app, 'my-app');
|
|
394
|
-
assert.equal(sent[0].tags.version, '1.0.0');
|
|
203
|
+
assert.equal(isDefaultExportSpan(span), true);
|
|
395
204
|
});
|
|
396
205
|
|
|
397
|
-
test('
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
let capturedArgs;
|
|
402
|
-
const raw = {
|
|
403
|
-
chat: { completions: { create: async (args) => { capturedArgs = args; return { id: 'res-1', choices: [] }; } } },
|
|
404
|
-
};
|
|
405
|
-
weflayr_setup({ ...TEST_CREDS, methods: [{ call: 'chat.completions.create' }] });
|
|
406
|
-
const client = weflayr_instrument(raw);
|
|
407
|
-
|
|
408
|
-
const sent = await captureHttp(async () => {
|
|
409
|
-
// SNIPPET_START: quickstart_call
|
|
410
|
-
const response = await client.chat.completions.create({
|
|
411
|
-
model: 'gpt-4o-mini',
|
|
412
|
-
messages: [{ role: 'user', content: 'Hello!' }],
|
|
413
|
-
__weflayr_tags: {
|
|
414
|
-
provider: 'openai',
|
|
415
|
-
feature: 'onboarding',
|
|
416
|
-
customer_id: '#3756'
|
|
417
|
-
},
|
|
418
|
-
});
|
|
419
|
-
// response is the standard OpenAI response object — unchanged
|
|
420
|
-
// SNIPPET_END: quickstart_call
|
|
421
|
-
|
|
422
|
-
assert.equal(response.id, 'res-1');
|
|
206
|
+
test('isDefaultExportSpan rejects unrelated spans', () => {
|
|
207
|
+
const span = makeFakeSpan({
|
|
208
|
+
attributes: { 'http.method': 'GET' },
|
|
209
|
+
instrumentationScope: { name: 'http' },
|
|
423
210
|
});
|
|
424
|
-
|
|
425
|
-
assert.ok(!('__weflayr_tags' in capturedArgs), 'tags stripped from underlying call');
|
|
426
|
-
const before = sent.find(e => e.event_type === 'before');
|
|
427
|
-
assert.ok(before, 'before event sent');
|
|
428
|
-
assert.deepEqual(before.tags, { provider: 'openai', feature: 'onboarding', customer_id: '#3756' });
|
|
211
|
+
assert.equal(isDefaultExportSpan(span), false);
|
|
429
212
|
});
|
|
430
213
|
|
|
431
|
-
|
|
432
|
-
freshSDK();
|
|
433
|
-
setSnippetEnv();
|
|
434
|
-
|
|
435
|
-
const sent = await captureHttp(async () => {
|
|
436
|
-
// SNIPPET_START: openai_full_coverage
|
|
437
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
438
|
-
|
|
439
|
-
weflayr_setup({
|
|
440
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
441
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
442
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
443
|
-
methods: [
|
|
444
|
-
{ call: 'chat.completions.create' },
|
|
445
|
-
{ call: 'completions.create' },
|
|
446
|
-
{ call: 'embeddings.create' },
|
|
447
|
-
{ call: 'responses.create' },
|
|
448
|
-
{
|
|
449
|
-
call: 'audio.speech.create',
|
|
450
|
-
middleware: (args) => ({ char_count: args?.input?.length ?? 0 }),
|
|
451
|
-
},
|
|
452
|
-
{ call: 'audio.transcriptions.create' },
|
|
453
|
-
{ call: 'audio.translations.create' },
|
|
454
|
-
],
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
const OpenAI = require('openai');
|
|
458
|
-
const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
|
|
459
|
-
// SNIPPET_END: openai_full_coverage
|
|
214
|
+
// ── WeflayrSpanProcessor ──────────────────────────────────────────────────────
|
|
460
215
|
|
|
461
|
-
|
|
462
|
-
|
|
216
|
+
test('WeflayrSpanProcessor stamps defaultTags on span start', () => {
|
|
217
|
+
const exporter = makeExporterStub();
|
|
218
|
+
const proc = new WeflayrSpanProcessor({
|
|
219
|
+
exporter,
|
|
220
|
+
defaultTags: { app: 'svc-A', env: 'prod' },
|
|
463
221
|
});
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
assert.
|
|
467
|
-
assert.equal(
|
|
222
|
+
const span = makeFakeSpan();
|
|
223
|
+
proc.onStart(span, otelContext.active());
|
|
224
|
+
assert.equal(span.attributes['weflayr.tag.app'], 'svc-A');
|
|
225
|
+
assert.equal(span.attributes['weflayr.tag.env'], 'prod');
|
|
468
226
|
});
|
|
469
227
|
|
|
470
|
-
test('
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
weflayr_setup({ ...TEST_CREDS, methods: [{ call: 'chat.completions.create' }] });
|
|
479
|
-
const client = weflayr_instrument(raw);
|
|
480
|
-
|
|
481
|
-
const sent = await captureHttp(async () => {
|
|
482
|
-
// SNIPPET_START: openai_tagging
|
|
483
|
-
await client.chat.completions.create({
|
|
484
|
-
model: 'gpt-4o-mini',
|
|
485
|
-
messages: [{ role: 'user', content: 'Hello' }],
|
|
486
|
-
__weflayr_tags: {
|
|
487
|
-
provider: 'openai',
|
|
488
|
-
feature: 'support-chat',
|
|
489
|
-
customer_id: 'acme-corp',
|
|
490
|
-
env: 'production',
|
|
491
|
-
},
|
|
492
|
-
});
|
|
493
|
-
// SNIPPET_END: openai_tagging
|
|
494
|
-
});
|
|
228
|
+
test('WeflayrSpanProcessor forwards GenAI spans to the exporter', async () => {
|
|
229
|
+
const exporter = makeExporterStub();
|
|
230
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
231
|
+
const span = makeFakeSpan({ attributes: { 'gen_ai.system': 'openai' } });
|
|
232
|
+
proc.onEnd(span);
|
|
233
|
+
await proc.forceFlush();
|
|
234
|
+
assert.equal(exporter.exported.length, 1);
|
|
235
|
+
});
|
|
495
236
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
237
|
+
test('WeflayrSpanProcessor drops non-GenAI spans', async () => {
|
|
238
|
+
const exporter = makeExporterStub();
|
|
239
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
240
|
+
proc.onEnd(
|
|
241
|
+
makeFakeSpan({
|
|
242
|
+
attributes: { 'http.method': 'GET' },
|
|
243
|
+
instrumentationScope: { name: 'http' },
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
await proc.forceFlush();
|
|
247
|
+
assert.equal(exporter.exported.length, 0);
|
|
501
248
|
});
|
|
502
249
|
|
|
503
|
-
test('
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
weflayr_setup({
|
|
512
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
513
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
514
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
515
|
-
ignore_fields: (data) => {
|
|
516
|
-
(data.data ?? []).forEach(img => delete img.b64_json);
|
|
517
|
-
return data;
|
|
518
|
-
},
|
|
519
|
-
methods: [
|
|
520
|
-
{ call: 'images.generate' },
|
|
521
|
-
],
|
|
522
|
-
});
|
|
250
|
+
test('WeflayrSpanProcessor ends orphaned LLM spans on forceFlush', async () => {
|
|
251
|
+
// LLM spans that started but never ended should be force-ended with ERROR on
|
|
252
|
+
// flush — covers openllmetry-style leaks (e.g. anthropic streaming auth error).
|
|
253
|
+
const exporter = makeExporterStub();
|
|
254
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
255
|
+
const provider = new BasicTracerProvider({ spanProcessors: [proc] });
|
|
256
|
+
const tracer = provider.getTracer('opentelemetry.instrumentation.anthropic');
|
|
523
257
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
258
|
+
const leaked = tracer.startSpan('anthropic.chat');
|
|
259
|
+
leaked.setAttribute('gen_ai.system', 'anthropic');
|
|
260
|
+
assert.equal(exporter.exported.length, 0, 'span should not have been exported yet');
|
|
527
261
|
|
|
528
|
-
|
|
529
|
-
});
|
|
262
|
+
await proc.forceFlush();
|
|
530
263
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
assert.ok(after.response.data[0].url, 'url preserved in telemetry');
|
|
264
|
+
assert.equal(exporter.exported.length, 1);
|
|
265
|
+
// SpanStatusCode.ERROR === 2 in @opentelemetry/api
|
|
266
|
+
assert.equal(exporter.exported[0].status.code, 2);
|
|
267
|
+
await provider.shutdown();
|
|
537
268
|
});
|
|
538
269
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
270
|
+
test('WeflayrSpanProcessor applies mask before export', async () => {
|
|
271
|
+
const exporter = makeExporterStub();
|
|
272
|
+
let received = null;
|
|
273
|
+
const proc = new WeflayrSpanProcessor({
|
|
274
|
+
exporter,
|
|
275
|
+
exportMode: 'immediate',
|
|
276
|
+
mask: (attrs) => {
|
|
277
|
+
received = attrs;
|
|
278
|
+
attrs['gen_ai.prompt'] = '[REDACTED]';
|
|
279
|
+
return attrs;
|
|
280
|
+
},
|
|
550
281
|
});
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const sent = await captureHttp(async () => {
|
|
554
|
-
await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
282
|
+
const span = makeFakeSpan({
|
|
283
|
+
attributes: { 'gen_ai.system': 'openai', 'gen_ai.prompt': 'Hi Noe' },
|
|
555
284
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
assert.
|
|
559
|
-
assert.equal(
|
|
560
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
561
|
-
assert.equal(after.tags.app, 'my-app');
|
|
562
|
-
assert.equal(after.tags.version, '1.2.0');
|
|
285
|
+
proc.onEnd(span);
|
|
286
|
+
await proc.forceFlush();
|
|
287
|
+
assert.ok(received);
|
|
288
|
+
assert.equal(span.attributes['gen_ai.prompt'], '[REDACTED]');
|
|
563
289
|
});
|
|
564
290
|
|
|
565
|
-
|
|
566
|
-
const { weflayr_setup, weflayr_instrument, weflayr_propagate } = freshSDK();
|
|
567
|
-
|
|
568
|
-
weflayr_setup({
|
|
569
|
-
...TEST_CREDS,
|
|
570
|
-
methods: [{ call: 'chat.completions.create' }],
|
|
571
|
-
});
|
|
291
|
+
// ── propagateMetadata ────────────────────────────────────────────────────────
|
|
572
292
|
|
|
573
|
-
|
|
293
|
+
function _makeMetadataHarness() {
|
|
294
|
+
const exporter = makeExporterStub();
|
|
295
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
296
|
+
const provider = new BasicTracerProvider({ spanProcessors: [proc] });
|
|
297
|
+
const tracer = provider.getTracer('test');
|
|
298
|
+
return { exporter, proc, provider, tracer };
|
|
299
|
+
}
|
|
574
300
|
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
await client.chat.completions.create({ model: 'gpt-4o-mini', messages: [] });
|
|
578
|
-
});
|
|
301
|
+
test('propagateMetadata writes structured fields and extra tags', async () => {
|
|
302
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
579
303
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
304
|
+
await weflayr.propagateMetadata(
|
|
305
|
+
{
|
|
306
|
+
featureName: 'onboarding',
|
|
307
|
+
customerId: '#3756',
|
|
308
|
+
extraTags: { variant: 'control' },
|
|
309
|
+
},
|
|
310
|
+
async () => {
|
|
311
|
+
const span = tracer.startSpan('child');
|
|
312
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
313
|
+
span.end();
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await proc.forceFlush();
|
|
318
|
+
assert.equal(exporter.exported.length, 1);
|
|
319
|
+
const attrs = exporter.exported[0].attributes;
|
|
320
|
+
assert.equal(attrs['weflayr.feature_name'], 'onboarding');
|
|
321
|
+
assert.equal(attrs['weflayr.customer_id'], '#3756');
|
|
322
|
+
assert.equal(attrs['weflayr.tag.variant'], 'control');
|
|
323
|
+
assert.ok(attrs['weflayr.uuid']);
|
|
324
|
+
assert.equal(attrs['weflayr.provider_name'], undefined);
|
|
325
|
+
await provider.shutdown();
|
|
584
326
|
});
|
|
585
327
|
|
|
586
|
-
test('
|
|
587
|
-
const {
|
|
328
|
+
test('propagateMetadata uuid is stable within scope and changes across calls', async () => {
|
|
329
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
588
330
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
331
|
+
await weflayr.propagateMetadata({ featureName: 'f', customerId: 'c' }, async () => {
|
|
332
|
+
const spanA = tracer.startSpan('a');
|
|
333
|
+
spanA.setAttribute('gen_ai.system', 'openai');
|
|
334
|
+
spanA.end();
|
|
335
|
+
const spanB = tracer.startSpan('b');
|
|
336
|
+
spanB.setAttribute('gen_ai.system', 'openai');
|
|
337
|
+
spanB.end();
|
|
593
338
|
});
|
|
594
339
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
await client.chat.completions.create({
|
|
600
|
-
model: 'gpt-4o-mini',
|
|
601
|
-
messages: [],
|
|
602
|
-
__weflayr_tags: { env: 'test' }, // overrides both
|
|
603
|
-
});
|
|
340
|
+
await weflayr.propagateMetadata({ featureName: 'f', customerId: 'c' }, async () => {
|
|
341
|
+
const spanC = tracer.startSpan('c_span');
|
|
342
|
+
spanC.setAttribute('gen_ai.system', 'openai');
|
|
343
|
+
spanC.end();
|
|
604
344
|
});
|
|
605
345
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
346
|
+
await proc.forceFlush();
|
|
347
|
+
const uuidA = exporter.exported[0].attributes['weflayr.uuid'];
|
|
348
|
+
const uuidB = exporter.exported[1].attributes['weflayr.uuid'];
|
|
349
|
+
const uuidC = exporter.exported[2].attributes['weflayr.uuid'];
|
|
350
|
+
assert.equal(uuidA, uuidB);
|
|
351
|
+
assert.notEqual(uuidA, uuidC);
|
|
352
|
+
await provider.shutdown();
|
|
609
353
|
});
|
|
610
354
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
// ---------------------------------------------------------------------------
|
|
614
|
-
|
|
615
|
-
test('snippet propagate_default_tags: default_tags in every event', async () => {
|
|
616
|
-
freshSDK();
|
|
617
|
-
setSnippetEnv();
|
|
618
|
-
|
|
619
|
-
const sent = await captureHttp(async () => {
|
|
620
|
-
// SNIPPET_START: propagate_default_tags
|
|
621
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
622
|
-
|
|
623
|
-
weflayr_setup({
|
|
624
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
625
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
626
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
627
|
-
default_tags: {
|
|
628
|
-
app: 'my-app',
|
|
629
|
-
version: '1.2.0',
|
|
630
|
-
env: 'production',
|
|
631
|
-
},
|
|
632
|
-
methods: [{ call: 'chat.completions.create' }],
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
const OpenAI = require('openai');
|
|
636
|
-
const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
|
|
637
|
-
// SNIPPET_END: propagate_default_tags
|
|
355
|
+
test('propagateMetadata nested scopes inner overrides and extra tags merge', async () => {
|
|
356
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
638
357
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
358
|
+
await weflayr.propagateMetadata(
|
|
359
|
+
{
|
|
360
|
+
featureName: 'outer',
|
|
361
|
+
customerId: 'outer_c',
|
|
362
|
+
extraTags: { variant: 'A', shared: 'outer' },
|
|
363
|
+
},
|
|
364
|
+
async () => {
|
|
365
|
+
await weflayr.propagateMetadata(
|
|
366
|
+
{
|
|
367
|
+
featureName: 'inner',
|
|
368
|
+
customerId: 'inner_c',
|
|
369
|
+
extraTags: { shared: 'inner', experiment: 'B' },
|
|
370
|
+
},
|
|
371
|
+
async () => {
|
|
372
|
+
const span = tracer.startSpan('child');
|
|
373
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
374
|
+
span.end();
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await proc.forceFlush();
|
|
381
|
+
const attrs = exporter.exported[0].attributes;
|
|
382
|
+
assert.equal(attrs['weflayr.feature_name'], 'inner');
|
|
383
|
+
assert.equal(attrs['weflayr.customer_id'], 'inner_c');
|
|
384
|
+
// Outer extra tag not shadowed survives
|
|
385
|
+
assert.equal(attrs['weflayr.tag.variant'], 'A');
|
|
386
|
+
// Inner extra tag wins on conflict
|
|
387
|
+
assert.equal(attrs['weflayr.tag.shared'], 'inner');
|
|
388
|
+
assert.equal(attrs['weflayr.tag.experiment'], 'B');
|
|
389
|
+
await provider.shutdown();
|
|
650
390
|
});
|
|
651
391
|
|
|
652
|
-
test('
|
|
653
|
-
const {
|
|
654
|
-
setSnippetEnv();
|
|
392
|
+
test('propagateMetadata decorator mints fresh uuid per invocation', async () => {
|
|
393
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
655
394
|
|
|
656
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const client = weflayr_instrument(raw);
|
|
662
|
-
|
|
663
|
-
const sent = await captureHttp(async () => {
|
|
664
|
-
// SNIPPET_START: propagate_runtime
|
|
665
|
-
const { weflayr_propagate } = require('weflayr');
|
|
666
|
-
|
|
667
|
-
// In a request handler — tags every LLM call made during this execution
|
|
668
|
-
weflayr_propagate('customer_id', 'acme-corp');
|
|
669
|
-
|
|
670
|
-
await client.chat.completions.create({
|
|
671
|
-
model: 'gpt-4o-mini',
|
|
672
|
-
messages: [{ role: 'user', content: 'Hello!' }],
|
|
673
|
-
});
|
|
674
|
-
// SNIPPET_END: propagate_runtime
|
|
395
|
+
const wrap = weflayr.propagateMetadata({ featureName: 'f', customerId: 'c' });
|
|
396
|
+
const leaf = wrap(async function leaf() {
|
|
397
|
+
const span = tracer.startSpan('child');
|
|
398
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
399
|
+
span.end();
|
|
675
400
|
});
|
|
676
401
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
assert.ok(before, 'before event sent');
|
|
680
|
-
assert.equal(before.tags.customer_id, 'acme-corp');
|
|
681
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
682
|
-
assert.ok(after, 'after event sent');
|
|
683
|
-
assert.equal(after.tags.customer_id, 'acme-corp');
|
|
684
|
-
});
|
|
402
|
+
await leaf();
|
|
403
|
+
await leaf();
|
|
685
404
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
test('snippet mastra_setup: wraps Mastra Agent, sends before+after events on generate()', async () => {
|
|
692
|
-
freshSDK();
|
|
693
|
-
setSnippetEnv();
|
|
694
|
-
|
|
695
|
-
const sent = await captureHttp(async () => {
|
|
696
|
-
// SNIPPET_START: mastra_setup
|
|
697
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
698
|
-
const { Agent } = require('@mastra/core');
|
|
699
|
-
const { openai } = require('@ai-sdk/openai');
|
|
700
|
-
|
|
701
|
-
weflayr_setup({
|
|
702
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
703
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
704
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
705
|
-
methods: [{ call: 'generate' }],
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
const agent = weflayr_instrument(
|
|
709
|
-
new Agent({
|
|
710
|
-
name: 'my-agent',
|
|
711
|
-
model: openai('gpt-4o-mini'),
|
|
712
|
-
instructions: 'You are a helpful assistant.',
|
|
713
|
-
})
|
|
714
|
-
);
|
|
715
|
-
// SNIPPET_END: mastra_setup
|
|
716
|
-
|
|
717
|
-
await agent.generate([{ role: 'user', content: 'Hello' }]);
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
assert.ok(sent.some(e => e.event_type === 'before'), 'before event sent');
|
|
721
|
-
assert.ok(sent.some(e => e.event_type === 'after'), 'after event sent');
|
|
722
|
-
assert.equal(sent[0].method, 'generate');
|
|
405
|
+
await proc.forceFlush();
|
|
406
|
+
const uuidFirst = exporter.exported[0].attributes['weflayr.uuid'];
|
|
407
|
+
const uuidSecond = exporter.exported[1].attributes['weflayr.uuid'];
|
|
408
|
+
assert.notEqual(uuidFirst, uuidSecond);
|
|
409
|
+
await provider.shutdown();
|
|
723
410
|
});
|
|
724
411
|
|
|
725
|
-
test('
|
|
726
|
-
const {
|
|
727
|
-
setSnippetEnv();
|
|
728
|
-
|
|
729
|
-
const { Agent } = require('@mastra/core');
|
|
730
|
-
const { openai } = require('@ai-sdk/openai');
|
|
731
|
-
weflayr_setup({ ...TEST_CREDS, methods: [{ call: 'generate' }] });
|
|
732
|
-
const agent = weflayr_instrument(new Agent({ name: 'test-agent', model: openai('gpt-4o-mini'), instructions: '...' }));
|
|
412
|
+
test('propagateMetadata decorator form wraps a function', async () => {
|
|
413
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
733
414
|
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
{ __weflayr_tags: { feature: 'chat', customer_id: '42', env: 'prod' } },
|
|
739
|
-
);
|
|
740
|
-
// SNIPPET_END: mastra_tags
|
|
415
|
+
const wrap = weflayr.propagateMetadata({
|
|
416
|
+
featureName: 'chat',
|
|
417
|
+
customerId: 'c_42',
|
|
418
|
+
extraTags: { experiment: 'B' },
|
|
741
419
|
});
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test('snippet mastra_content_privacy: text/request/steps/response content stripped from events', async () => {
|
|
749
|
-
freshSDK();
|
|
750
|
-
setSnippetEnv();
|
|
751
|
-
|
|
752
|
-
const sent = await captureHttp(async () => {
|
|
753
|
-
// SNIPPET_START: mastra_content_privacy
|
|
754
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
755
|
-
const { Agent } = require('@mastra/core');
|
|
756
|
-
const { openai } = require('@ai-sdk/openai');
|
|
757
|
-
|
|
758
|
-
weflayr_setup({
|
|
759
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
760
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
761
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
762
|
-
methods: [{ call: 'generate' }],
|
|
763
|
-
ignore_fields: (data) => {
|
|
764
|
-
delete data.text;
|
|
765
|
-
delete data.request;
|
|
766
|
-
delete data.steps;
|
|
767
|
-
if (data.response) {
|
|
768
|
-
delete data.response.body;
|
|
769
|
-
delete data.response.messages;
|
|
770
|
-
}
|
|
771
|
-
return data;
|
|
772
|
-
},
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
const agent = weflayr_instrument(
|
|
776
|
-
new Agent({
|
|
777
|
-
name: 'my-agent',
|
|
778
|
-
model: openai('gpt-4o-mini'),
|
|
779
|
-
instructions: 'You are a helpful assistant.',
|
|
780
|
-
})
|
|
781
|
-
);
|
|
782
|
-
// SNIPPET_END: mastra_content_privacy
|
|
783
|
-
|
|
784
|
-
await agent.generate([{ role: 'user', content: 'Hello' }]);
|
|
420
|
+
const leaf = wrap(async function leaf() {
|
|
421
|
+
const span = tracer.startSpan('child');
|
|
422
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
423
|
+
span.end();
|
|
785
424
|
});
|
|
786
425
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
assert.
|
|
791
|
-
assert.
|
|
792
|
-
assert.
|
|
793
|
-
|
|
426
|
+
await leaf();
|
|
427
|
+
await proc.forceFlush();
|
|
428
|
+
const attrs = exporter.exported[0].attributes;
|
|
429
|
+
assert.equal(attrs['weflayr.feature_name'], 'chat');
|
|
430
|
+
assert.equal(attrs['weflayr.customer_id'], 'c_42');
|
|
431
|
+
assert.equal(attrs['weflayr.tag.experiment'], 'B');
|
|
432
|
+
await provider.shutdown();
|
|
794
433
|
});
|
|
795
434
|
|
|
796
|
-
test('
|
|
797
|
-
|
|
798
|
-
setSnippetEnv();
|
|
799
|
-
|
|
800
|
-
const sent = await captureHttp(async () => {
|
|
801
|
-
// SNIPPET_START: mastra_stream
|
|
802
|
-
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
803
|
-
const { Agent } = require('@mastra/core');
|
|
804
|
-
const { openai } = require('@ai-sdk/openai');
|
|
805
|
-
|
|
806
|
-
weflayr_setup({
|
|
807
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
808
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
809
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
810
|
-
methods: [
|
|
811
|
-
{ call: 'generate' },
|
|
812
|
-
{
|
|
813
|
-
call: 'stream',
|
|
814
|
-
streamPath: 'textStream',
|
|
815
|
-
streamMiddleware: (fullResult) => ({
|
|
816
|
-
onChunk: () => false,
|
|
817
|
-
async finalize() {
|
|
818
|
-
const rawUsage = await fullResult.usage;
|
|
819
|
-
const rawResponse = await fullResult.response;
|
|
820
|
-
const response = rawResponse?.status?.value ?? rawResponse;
|
|
821
|
-
return {
|
|
822
|
-
usage: rawUsage,
|
|
823
|
-
model: response?.modelId ?? null,
|
|
824
|
-
};
|
|
825
|
-
},
|
|
826
|
-
}),
|
|
827
|
-
},
|
|
828
|
-
],
|
|
829
|
-
ignore_fields: (data) => {
|
|
830
|
-
delete data.text;
|
|
831
|
-
delete data.request;
|
|
832
|
-
delete data.steps;
|
|
833
|
-
if (data.response) {
|
|
834
|
-
delete data.response.body;
|
|
835
|
-
delete data.response.messages;
|
|
836
|
-
}
|
|
837
|
-
return data;
|
|
838
|
-
},
|
|
839
|
-
});
|
|
435
|
+
test('propagateMetadata callable form runs function', async () => {
|
|
436
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
840
437
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
);
|
|
853
|
-
|
|
854
|
-
let text = '';
|
|
855
|
-
for await (const chunk of streamResult.textStream) {
|
|
856
|
-
text += chunk;
|
|
438
|
+
const result = await weflayr.propagateMetadata(
|
|
439
|
+
{
|
|
440
|
+
featureName: 'callable',
|
|
441
|
+
customerId: 'c_42',
|
|
442
|
+
extraTags: { variant: 'A' },
|
|
443
|
+
},
|
|
444
|
+
async () => {
|
|
445
|
+
const span = tracer.startSpan('child');
|
|
446
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
447
|
+
span.end();
|
|
448
|
+
return 'done';
|
|
857
449
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
assert.
|
|
864
|
-
assert.
|
|
865
|
-
|
|
866
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
867
|
-
assert.equal(after.method, 'stream');
|
|
868
|
-
assert.equal(after.model, 'gpt-4o-mini');
|
|
869
|
-
assert.ok(after.usage, 'usage present in after event');
|
|
870
|
-
assert.equal(after.tags.feature, 'chat');
|
|
871
|
-
assert.equal(after.tags.customer_id, '42');
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await proc.forceFlush();
|
|
453
|
+
assert.equal(result, 'done');
|
|
454
|
+
const attrs = exporter.exported[0].attributes;
|
|
455
|
+
assert.equal(attrs['weflayr.feature_name'], 'callable');
|
|
456
|
+
assert.equal(attrs['weflayr.tag.variant'], 'A');
|
|
457
|
+
await provider.shutdown();
|
|
872
458
|
});
|
|
873
459
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
// ---------------------------------------------------------------------------
|
|
877
|
-
|
|
878
|
-
test('weflayr_vercel_ai_middleware: wrapGenerate sends before + after with model and tags', async () => {
|
|
879
|
-
const { weflayr_setup, weflayr_vercel_ai_middleware } = freshSDK();
|
|
880
|
-
|
|
881
|
-
weflayr_setup({ ...TEST_CREDS });
|
|
460
|
+
test('propagateMetadata providerName writes attribute', async () => {
|
|
461
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
882
462
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
}),
|
|
896
|
-
params: {},
|
|
897
|
-
model: {},
|
|
898
|
-
});
|
|
899
|
-
});
|
|
463
|
+
await weflayr.propagateMetadata(
|
|
464
|
+
{
|
|
465
|
+
featureName: 'f',
|
|
466
|
+
customerId: 'c',
|
|
467
|
+
providerName: weflayr.AIProviderName.VERCEL_AI_GATEWAY,
|
|
468
|
+
},
|
|
469
|
+
async () => {
|
|
470
|
+
const span = tracer.startSpan('child');
|
|
471
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
472
|
+
span.end();
|
|
473
|
+
}
|
|
474
|
+
);
|
|
900
475
|
|
|
901
|
-
|
|
902
|
-
assert.
|
|
903
|
-
|
|
904
|
-
assert.equal(before.method, 'vercel.generate');
|
|
905
|
-
assert.equal(before.args.model, 'openai/gpt-4o-mini');
|
|
906
|
-
assert.equal(before.tags.feature, 'chat');
|
|
907
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
908
|
-
assert.deepEqual(after.response.usage, { promptTokens: 10, completionTokens: 5 });
|
|
909
|
-
assert.equal(after.response.finish_reason, 'stop');
|
|
910
|
-
assert.ok(after.elapsed_ms >= 0);
|
|
476
|
+
await proc.forceFlush();
|
|
477
|
+
assert.equal(exporter.exported[0].attributes['weflayr.provider_name'], 'vercel_ai_gateway');
|
|
478
|
+
await provider.shutdown();
|
|
911
479
|
});
|
|
912
480
|
|
|
913
|
-
|
|
914
|
-
const { weflayr_setup, weflayr_vercel_ai_middleware } = freshSDK();
|
|
481
|
+
// ── autoInstrument ────────────────────────────────────────────────────────────
|
|
915
482
|
|
|
916
|
-
|
|
483
|
+
function _ensureSetupDone() {
|
|
484
|
+
if (!_state.processor) weflayr.setup({ apiKey: 'test-key' });
|
|
485
|
+
}
|
|
917
486
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
487
|
+
test('autoInstrument throws "install package X" when the dep is missing', async () => {
|
|
488
|
+
_ensureSetupDone();
|
|
489
|
+
await assert.rejects(
|
|
490
|
+
() => weflayr.autoInstrument({ aiSdks: weflayr.AiSdk.OPENAI }),
|
|
491
|
+
/Install it with: npm install @traceloop\/instrumentation-openai/
|
|
492
|
+
);
|
|
493
|
+
});
|
|
922
494
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
ctrl.enqueue({ type: 'finish', finishReason: 'stop', usage: { promptTokens: 10, completionTokens: 5 } });
|
|
930
|
-
ctrl.close();
|
|
931
|
-
},
|
|
932
|
-
}),
|
|
933
|
-
rawCall: {}, rawResponse: {}, warnings: [],
|
|
934
|
-
}),
|
|
935
|
-
params: {},
|
|
936
|
-
model: {},
|
|
937
|
-
});
|
|
495
|
+
test('autoInstrument rejects unknown providers', async () => {
|
|
496
|
+
// JS keeps this runtime check because `AiSdk` is a frozen object — it
|
|
497
|
+
// doesn't enforce membership at the value level the way Python's `Enum` does.
|
|
498
|
+
_ensureSetupDone();
|
|
499
|
+
await assert.rejects(() => weflayr.autoInstrument({ aiSdks: 'unicorn-llm' }), /unknown provider/);
|
|
500
|
+
});
|
|
938
501
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
502
|
+
test('autoInstrument loads a stubbed Traceloop package', async () => {
|
|
503
|
+
_ensureSetupDone();
|
|
504
|
+
let constructed = false;
|
|
505
|
+
class FakeOpenAIInstrumentation {
|
|
506
|
+
constructor() {
|
|
507
|
+
constructed = true;
|
|
943
508
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
509
|
+
enable() {}
|
|
510
|
+
disable() {}
|
|
511
|
+
setTracerProvider() {}
|
|
512
|
+
setMeterProvider() {}
|
|
513
|
+
setConfig() {}
|
|
514
|
+
getConfig() {
|
|
515
|
+
return {};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const origResolve = Module._resolveFilename;
|
|
519
|
+
Module._resolveFilename = function patched(req, ...rest) {
|
|
520
|
+
if (req === '@traceloop/instrumentation-openai') return req;
|
|
521
|
+
return origResolve.call(this, req, ...rest);
|
|
522
|
+
};
|
|
523
|
+
require.cache['@traceloop/instrumentation-openai'] = {
|
|
524
|
+
id: '@traceloop/instrumentation-openai',
|
|
525
|
+
filename: '@traceloop/instrumentation-openai',
|
|
526
|
+
loaded: true,
|
|
527
|
+
exports: { OpenAIInstrumentation: FakeOpenAIInstrumentation },
|
|
528
|
+
};
|
|
529
|
+
try {
|
|
530
|
+
await weflayr.autoInstrument({ aiSdks: weflayr.AiSdk.OPENAI });
|
|
531
|
+
assert.equal(constructed, true);
|
|
532
|
+
} finally {
|
|
533
|
+
Module._resolveFilename = origResolve;
|
|
534
|
+
delete require.cache['@traceloop/instrumentation-openai'];
|
|
535
|
+
}
|
|
954
536
|
});
|
|
955
537
|
|
|
956
|
-
test('
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
538
|
+
test('autoInstrument throws when no apiKey is reachable (env nor argument)', async () => {
|
|
539
|
+
_state.processor = null;
|
|
540
|
+
const saved = process.env.WEFLAYR_API_KEY;
|
|
541
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
542
|
+
try {
|
|
543
|
+
await assert.rejects(
|
|
544
|
+
() => weflayr.autoInstrument({ aiSdks: weflayr.AiSdk.OPENAI }),
|
|
545
|
+
/apiKey.*required/
|
|
546
|
+
);
|
|
547
|
+
} finally {
|
|
548
|
+
if (saved !== undefined) process.env.WEFLAYR_API_KEY = saved;
|
|
549
|
+
}
|
|
550
|
+
});
|
|
963
551
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
552
|
+
test('autoInstrument runs setup when apiKey passed', async () => {
|
|
553
|
+
_state.processor = null;
|
|
554
|
+
class FakeOpenAIInstrumentation {
|
|
555
|
+
constructor() {}
|
|
556
|
+
enable() {}
|
|
557
|
+
disable() {}
|
|
558
|
+
setTracerProvider() {}
|
|
559
|
+
setMeterProvider() {}
|
|
560
|
+
setConfig() {}
|
|
561
|
+
getConfig() {
|
|
562
|
+
return {};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const origResolve = Module._resolveFilename;
|
|
566
|
+
Module._resolveFilename = function patched(req, ...rest) {
|
|
567
|
+
if (req === '@traceloop/instrumentation-openai') return req;
|
|
568
|
+
return origResolve.call(this, req, ...rest);
|
|
569
|
+
};
|
|
570
|
+
require.cache['@traceloop/instrumentation-openai'] = {
|
|
571
|
+
id: '@traceloop/instrumentation-openai',
|
|
572
|
+
filename: '@traceloop/instrumentation-openai',
|
|
573
|
+
loaded: true,
|
|
574
|
+
exports: { OpenAIInstrumentation: FakeOpenAIInstrumentation },
|
|
575
|
+
};
|
|
576
|
+
try {
|
|
577
|
+
await weflayr.autoInstrument({
|
|
578
|
+
aiSdks: weflayr.AiSdk.OPENAI,
|
|
579
|
+
apiKey: 'convenience-key',
|
|
969
580
|
});
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
assert.equal(after.tags.customer_id, 'acme-corp');
|
|
581
|
+
assert.ok(_state.processor, 'processor should be installed by the embedded setup() call');
|
|
582
|
+
} finally {
|
|
583
|
+
Module._resolveFilename = origResolve;
|
|
584
|
+
delete require.cache['@traceloop/instrumentation-openai'];
|
|
585
|
+
}
|
|
976
586
|
});
|
|
977
587
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
test('snippet vercel_ai_setup: before+after via generateText, method and args correct', async () => {
|
|
984
|
-
freshSDK();
|
|
985
|
-
setSnippetEnv();
|
|
986
|
-
|
|
987
|
-
const sent = await captureHttp(async () => {
|
|
988
|
-
// SNIPPET_START: vercel_ai_setup
|
|
989
|
-
const { weflayr_setup, weflayr_vercel_ai_middleware } = require('weflayr');
|
|
990
|
-
const { wrapLanguageModel, generateText } = require('ai');
|
|
991
|
-
const { createOpenAICompatible } = require('@ai-sdk/openai-compatible');
|
|
992
|
-
|
|
993
|
-
weflayr_setup({
|
|
994
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
995
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
996
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
997
|
-
});
|
|
588
|
+
test('autoInstrument requires aiSdks', async () => {
|
|
589
|
+
_state.processor = null;
|
|
590
|
+
await assert.rejects(() => weflayr.autoInstrument({ apiKey: 'k' }), /`aiSdks` is required/);
|
|
591
|
+
});
|
|
998
592
|
|
|
999
|
-
|
|
1000
|
-
name: 'openai',
|
|
1001
|
-
baseURL: 'https://api.openai.com/v1',
|
|
1002
|
-
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
1003
|
-
});
|
|
593
|
+
// ── exporter retry ────────────────────────────────────────────────────────────
|
|
1004
594
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
model: 'openai/gpt-4o-mini',
|
|
1009
|
-
tags: { env: 'production' },
|
|
1010
|
-
}),
|
|
1011
|
-
});
|
|
595
|
+
function _failResult(status) {
|
|
596
|
+
return { code: 1, error: { code: status, message: `HTTP ${status}` } };
|
|
597
|
+
}
|
|
1012
598
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
599
|
+
test('exporter retries on 503 then succeeds', async () => {
|
|
600
|
+
const responses = [_failResult(503), _failResult(503), { code: 0 }];
|
|
601
|
+
let i = 0;
|
|
602
|
+
const fakeExport = (spans, cb) => cb(responses[i++]);
|
|
603
|
+
const wrapped = _exportWithRetry(fakeExport, { maxRetries: 3, maxRetryMs: 5000 });
|
|
604
|
+
const result = await new Promise((resolve) => wrapped([], resolve));
|
|
605
|
+
assert.equal(result.code, 0);
|
|
606
|
+
assert.equal(i, 3);
|
|
607
|
+
});
|
|
1016
608
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
assert.
|
|
609
|
+
test('exporter does not retry on 401', async () => {
|
|
610
|
+
let calls = 0;
|
|
611
|
+
const fakeExport = (spans, cb) => {
|
|
612
|
+
calls++;
|
|
613
|
+
cb(_failResult(401));
|
|
614
|
+
};
|
|
615
|
+
const wrapped = _exportWithRetry(fakeExport, { maxRetries: 3, maxRetryMs: 5000 });
|
|
616
|
+
const result = await new Promise((resolve) => wrapped([], resolve));
|
|
617
|
+
assert.equal(result.code, 1);
|
|
618
|
+
assert.equal(result.error.code, 401);
|
|
619
|
+
assert.equal(calls, 1);
|
|
1026
620
|
});
|
|
1027
621
|
|
|
1028
|
-
test('
|
|
1029
|
-
const
|
|
1030
|
-
|
|
622
|
+
test('exporter swallows unexpected exceptions', async () => {
|
|
623
|
+
const fakeExport = () => {
|
|
624
|
+
throw new Error('boom');
|
|
625
|
+
};
|
|
626
|
+
const wrapped = _exportWithRetry(fakeExport, { maxRetries: 1, maxRetryMs: 5000 });
|
|
627
|
+
const result = await new Promise((resolve) => wrapped([], resolve));
|
|
628
|
+
assert.equal(result.code, 1);
|
|
629
|
+
});
|
|
1031
630
|
|
|
1032
|
-
|
|
1033
|
-
|
|
631
|
+
test('exporter gives up after maxRetries on persistent 503', async () => {
|
|
632
|
+
let calls = 0;
|
|
633
|
+
const fakeExport = (spans, cb) => {
|
|
634
|
+
calls++;
|
|
635
|
+
cb(_failResult(503));
|
|
636
|
+
};
|
|
637
|
+
const wrapped = _exportWithRetry(fakeExport, { maxRetries: 3, maxRetryMs: 5000 });
|
|
638
|
+
const result = await new Promise((resolve) => wrapped([], resolve));
|
|
639
|
+
assert.equal(result.code, 1);
|
|
640
|
+
assert.equal(result.error.code, 503);
|
|
641
|
+
assert.equal(calls, 3);
|
|
642
|
+
});
|
|
1034
643
|
|
|
1035
|
-
|
|
644
|
+
// ── OpenAI streaming usage backfill ─────────────────────────────────────────────
|
|
1036
645
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
646
|
+
// Build a fake OpenAI instrumentor whose streaming handler mimics traceloop's:
|
|
647
|
+
// it yields content chunks, then a final usage chunk (as OpenAI sends when
|
|
648
|
+
// `stream_options.include_usage` is set) without recording usage itself. The
|
|
649
|
+
// patch should read the usage chunk and set the token attributes on the span.
|
|
650
|
+
function makeFakeOpenAIInstrumentation() {
|
|
651
|
+
return {
|
|
652
|
+
_streamingWrapPromise() {
|
|
653
|
+
return (async function* originalStream() {
|
|
654
|
+
yield { id: 'chatcmpl-1', model: 'gpt-4o-mini', choices: [{ delta: { content: 'Hi' } }] };
|
|
655
|
+
yield {
|
|
656
|
+
id: 'chatcmpl-1',
|
|
657
|
+
model: 'gpt-4o-mini',
|
|
658
|
+
choices: [],
|
|
659
|
+
usage: { prompt_tokens: 13, completion_tokens: 2, total_tokens: 15 },
|
|
660
|
+
};
|
|
661
|
+
})();
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
1042
665
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
666
|
+
test('patchOpenAIStreamingUsage sets token attributes from the streamed usage chunk', async () => {
|
|
667
|
+
const span = makeFakeSpan();
|
|
668
|
+
const instrumentation = makeFakeOpenAIInstrumentation();
|
|
669
|
+
patchOpenAIStreamingUsage(instrumentation);
|
|
1046
670
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
// SNIPPET_END: vercel_ai_propagate
|
|
1050
|
-
});
|
|
671
|
+
const stream = instrumentation._streamingWrapPromise({ span });
|
|
672
|
+
for await (const chunk of stream) void chunk;
|
|
1051
673
|
|
|
1052
|
-
|
|
1053
|
-
assert.
|
|
1054
|
-
assert.equal(
|
|
1055
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
1056
|
-
assert.equal(after.tags.customer_id, 'acme-corp');
|
|
674
|
+
assert.equal(span.attributes['gen_ai.usage.input_tokens'], 13);
|
|
675
|
+
assert.equal(span.attributes['gen_ai.usage.output_tokens'], 2);
|
|
676
|
+
assert.equal(span.attributes['gen_ai.usage.total_tokens'], 15);
|
|
1057
677
|
});
|
|
1058
678
|
|
|
1059
|
-
test('
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
// SNIPPET_START: vercel_ai_stream
|
|
1065
|
-
const { weflayr_setup, weflayr_vercel_ai_middleware } = require('weflayr');
|
|
1066
|
-
const { wrapLanguageModel, streamText } = require('ai');
|
|
1067
|
-
const { createOpenAICompatible } = require('@ai-sdk/openai-compatible');
|
|
1068
|
-
|
|
1069
|
-
weflayr_setup({
|
|
1070
|
-
intake_url: process.env.WEFLAYR_INTAKE_URL,
|
|
1071
|
-
client_id: process.env.WEFLAYR_CLIENT_ID,
|
|
1072
|
-
client_secret: process.env.WEFLAYR_CLIENT_SECRET,
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
const provider = createOpenAICompatible({
|
|
1076
|
-
name: 'openai',
|
|
1077
|
-
baseURL: 'https://api.openai.com/v1',
|
|
1078
|
-
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
const model = wrapLanguageModel({
|
|
1082
|
-
model: provider('gpt-4o-mini'),
|
|
1083
|
-
middleware: weflayr_vercel_ai_middleware({
|
|
1084
|
-
model: 'openai/gpt-4o-mini',
|
|
1085
|
-
tags: { feature: 'chat' },
|
|
1086
|
-
}),
|
|
1087
|
-
});
|
|
679
|
+
test('patchOpenAIStreamingUsage is a no-op when the streaming handler is absent', () => {
|
|
680
|
+
const instrumentation = {};
|
|
681
|
+
patchOpenAIStreamingUsage(instrumentation);
|
|
682
|
+
assert.equal(instrumentation._streamingWrapPromise, undefined);
|
|
683
|
+
});
|
|
1088
684
|
|
|
1089
|
-
|
|
1090
|
-
for await (const _chunk of textStream) {}
|
|
1091
|
-
// SNIPPET_END: vercel_ai_stream
|
|
1092
|
-
});
|
|
685
|
+
// ── flush ─────────────────────────────────────────────────────────────────────
|
|
1093
686
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
assert.ok(types.includes('stream_start'), 'stream_start event sent');
|
|
1097
|
-
assert.ok(types.includes('after'), 'after event sent');
|
|
1098
|
-
const after = sent.find(e => e.event_type === 'after');
|
|
1099
|
-
assert.equal(after.method, 'vercel.stream');
|
|
1100
|
-
assert.equal(after.tags.feature, 'chat');
|
|
1101
|
-
assert.ok(after.response.usage, 'usage in after event');
|
|
687
|
+
test('flush does not throw', async () => {
|
|
688
|
+
await weflayr.flush();
|
|
1102
689
|
});
|
|
690
|
+
|
|
691
|
+
void trace;
|