weflayr 0.22.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +2 -0
- package/dist/api/caseConversion.d.ts +16 -0
- package/dist/api/caseConversion.js +45 -0
- package/dist/api/generated/client/client.gen.d.ts +2 -0
- package/dist/api/generated/client/client.gen.js +220 -0
- package/dist/api/generated/client/index.d.ts +10 -0
- package/dist/api/generated/client/index.js +17 -0
- package/dist/api/generated/client/types.gen.d.ts +120 -0
- package/dist/api/generated/client/types.gen.js +3 -0
- package/dist/api/generated/client/utils.gen.d.ts +37 -0
- package/dist/api/generated/client/utils.gen.js +239 -0
- package/dist/api/generated/client.gen.d.ts +12 -0
- package/dist/api/generated/client.gen.js +6 -0
- package/dist/api/generated/core/auth.gen.d.ts +25 -0
- package/dist/api/generated/core/auth.gen.js +18 -0
- package/dist/api/generated/core/bodySerializer.gen.d.ts +25 -0
- package/dist/api/generated/core/bodySerializer.gen.js +60 -0
- package/dist/api/generated/core/params.gen.d.ts +43 -0
- package/dist/api/generated/core/params.gen.js +112 -0
- package/dist/api/generated/core/pathSerializer.gen.d.ts +33 -0
- package/dist/api/generated/core/pathSerializer.gen.js +115 -0
- package/dist/api/generated/core/queryKeySerializer.gen.d.ts +18 -0
- package/dist/api/generated/core/queryKeySerializer.gen.js +98 -0
- package/dist/api/generated/core/serverSentEvents.gen.d.ts +71 -0
- package/dist/api/generated/core/serverSentEvents.gen.js +135 -0
- package/dist/api/generated/core/types.gen.d.ts +83 -0
- package/dist/api/generated/core/types.gen.js +3 -0
- package/dist/api/generated/core/utils.gen.d.ts +19 -0
- package/dist/api/generated/core/utils.gen.js +93 -0
- package/dist/api/generated/facade.gen.d.ts +208 -0
- package/dist/api/generated/facade.gen.js +52 -0
- package/dist/api/generated/index.d.ts +2 -0
- package/dist/api/generated/index.js +17 -0
- package/dist/api/generated/sdk.gen.d.ts +63 -0
- package/dist/api/generated/sdk.gen.js +125 -0
- package/dist/api/generated/types.gen.d.ts +711 -0
- package/dist/api/generated/types.gen.js +3 -0
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +48 -0
- package/dist/api/makeEndpoint.d.ts +27 -0
- package/dist/api/makeEndpoint.js +37 -0
- package/index.d.ts +24 -15
- package/openapi-ts.config.mjs +9 -0
- package/package.json +9 -2
- package/scripts/generate_facade.mjs +86 -0
- package/src/api/caseConversion.ts +58 -0
- package/src/api/generated/client/client.gen.ts +277 -0
- package/src/api/generated/client/index.ts +27 -0
- package/src/api/generated/client/types.gen.ts +218 -0
- package/src/api/generated/client/utils.gen.ts +316 -0
- package/src/api/generated/client.gen.ts +16 -0
- package/src/api/generated/core/auth.gen.ts +48 -0
- package/src/api/generated/core/bodySerializer.gen.ts +82 -0
- package/src/api/generated/core/params.gen.ts +178 -0
- package/src/api/generated/core/pathSerializer.gen.ts +171 -0
- package/src/api/generated/core/queryKeySerializer.gen.ts +117 -0
- package/src/api/generated/core/serverSentEvents.gen.ts +242 -0
- package/src/api/generated/core/types.gen.ts +110 -0
- package/src/api/generated/core/utils.gen.ts +140 -0
- package/src/api/generated/facade.gen.ts +80 -0
- package/src/api/generated/index.ts +4 -0
- package/src/api/generated/sdk.gen.ts +139 -0
- package/src/api/generated/types.gen.ts +792 -0
- package/src/api/index.ts +45 -0
- package/src/api/makeEndpoint.ts +54 -0
- package/src/auto-instrument.js +19 -6
- package/src/index.js +5 -0
- package/src/instrumentation/google/get_provider_name.js +2 -1
- package/src/instrumentation/vercel/full_instrumentation.js +44 -0
- package/src/otel/propagation.js +21 -12
- package/src/otel/span-processor.js +11 -1
- package/src/otel/vercel-ai-sdk-span-filter.js +79 -0
- package/tests/api/index.test.js +330 -0
- package/tests/index.test.js +48 -15
- package/tests/instrumentation/vercel/full_instrumentation.test.js +16 -0
- package/tests/otel/vercel-ai-sdk-span-filter.test.js +159 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the public-API client: auth and base-URL wiring of the
|
|
4
|
+
// client facade, plus the request/response wiring of every generated
|
|
5
|
+
// endpoint (server behavior is covered by the dashboard's app_api tests).
|
|
6
|
+
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const http = require('node:http');
|
|
10
|
+
|
|
11
|
+
const weflayr = require('../../src/index');
|
|
12
|
+
const { api } = weflayr;
|
|
13
|
+
|
|
14
|
+
// A client whose server records requests and returns a canned response.
|
|
15
|
+
async function withClientReturning(responseBody, statusCode, run, clientOptions = {}) {
|
|
16
|
+
const seenRequests = [];
|
|
17
|
+
const server = http.createServer((request, response) => {
|
|
18
|
+
let requestBody = '';
|
|
19
|
+
request.on('data', (chunk) => {
|
|
20
|
+
requestBody += chunk;
|
|
21
|
+
});
|
|
22
|
+
request.on('end', () => {
|
|
23
|
+
seenRequests.push({
|
|
24
|
+
method: request.method,
|
|
25
|
+
url: request.url,
|
|
26
|
+
authorization: request.headers.authorization,
|
|
27
|
+
body: requestBody === '' ? null : JSON.parse(requestBody),
|
|
28
|
+
});
|
|
29
|
+
response.statusCode = statusCode;
|
|
30
|
+
response.setHeader('content-type', 'application/json');
|
|
31
|
+
response.end(JSON.stringify(responseBody));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
35
|
+
const client = api.client({
|
|
36
|
+
apiKey: 'wf-test-key',
|
|
37
|
+
...clientOptions,
|
|
38
|
+
baseUrl: `http://127.0.0.1:${server.address().port}/api`,
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
await run(client, seenRequests);
|
|
42
|
+
} finally {
|
|
43
|
+
server.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── client facade ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('client sends the bearer key to the configured host', async () => {
|
|
50
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
51
|
+
const { data } = await api.getAllRevenue({ client, throwOnError: true });
|
|
52
|
+
assert.deepEqual(data, { rows: [] });
|
|
53
|
+
assert.deepEqual(seenRequests, [
|
|
54
|
+
{ method: 'GET', url: '/api/revenue/', authorization: 'Bearer wf-test-key', body: null },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('weflayr.client is the same function as api.client', () => {
|
|
60
|
+
assert.equal(weflayr.client, api.client);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('client defaults to the production API host', () => {
|
|
64
|
+
const client = api.client({ apiKey: 'wf-test-key' });
|
|
65
|
+
assert.equal(client.getConfig().baseUrl, 'https://app.weflayr.com/api');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('client throws without apiKey', () => {
|
|
69
|
+
const saved = process.env.WEFLAYR_API_KEY;
|
|
70
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
71
|
+
try {
|
|
72
|
+
assert.throws(() => api.client(), /apiKey/);
|
|
73
|
+
} finally {
|
|
74
|
+
if (saved !== undefined) process.env.WEFLAYR_API_KEY = saved;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('client picks up apiKey from WEFLAYR_API_KEY env var', async () => {
|
|
79
|
+
process.env.WEFLAYR_API_KEY = 'env-key';
|
|
80
|
+
try {
|
|
81
|
+
await withClientReturning(
|
|
82
|
+
{ rows: [] },
|
|
83
|
+
200,
|
|
84
|
+
async (client, seenRequests) => {
|
|
85
|
+
await api.getAllRevenue({ client, throwOnError: true });
|
|
86
|
+
assert.equal(seenRequests[0].authorization, 'Bearer env-key');
|
|
87
|
+
},
|
|
88
|
+
{ apiKey: undefined }
|
|
89
|
+
);
|
|
90
|
+
} finally {
|
|
91
|
+
delete process.env.WEFLAYR_API_KEY;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Revenue ───────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
test('setRevenue posts the rows in snake_case and parses the booked count', async () => {
|
|
98
|
+
await withClientReturning({ ok: true, rows_inserted: 2 }, 200, async (client, seenRequests) => {
|
|
99
|
+
const rows = [
|
|
100
|
+
{ customerName: 'c_1', amount: '1200.50', month: '2026-06' },
|
|
101
|
+
{ customerName: 'c_2', amount: '800', month: '2026-06' },
|
|
102
|
+
];
|
|
103
|
+
const { data } = await api.setRevenue({ client, throwOnError: true, body: { rows } });
|
|
104
|
+
assert.deepEqual(data, { ok: true, rowsInserted: 2 });
|
|
105
|
+
const request = seenRequests[0];
|
|
106
|
+
assert.equal(request.method, 'POST');
|
|
107
|
+
assert.equal(request.url, '/api/revenue/');
|
|
108
|
+
assert.deepEqual(request.body, {
|
|
109
|
+
rows: [
|
|
110
|
+
{ customer_name: 'c_1', amount: '1200.50', month: '2026-06' },
|
|
111
|
+
{ customer_name: 'c_2', amount: '800', month: '2026-06' },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('getAllRevenue parses the returned rows into camelCase', async () => {
|
|
118
|
+
const wireRow = {
|
|
119
|
+
customer_name: 'c_1',
|
|
120
|
+
metric_name: 'revenue',
|
|
121
|
+
month: '2026-06',
|
|
122
|
+
amount: '1200.50000000',
|
|
123
|
+
source: 'api',
|
|
124
|
+
};
|
|
125
|
+
await withClientReturning({ rows: [wireRow] }, 200, async (client) => {
|
|
126
|
+
const { data } = await api.getAllRevenue({ client, throwOnError: true });
|
|
127
|
+
assert.deepEqual(data.rows, [
|
|
128
|
+
{
|
|
129
|
+
customerName: 'c_1',
|
|
130
|
+
metricName: 'revenue',
|
|
131
|
+
month: '2026-06',
|
|
132
|
+
amount: '1200.50000000',
|
|
133
|
+
source: 'api',
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('getCustomerRevenue requests the customer path', async () => {
|
|
140
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
141
|
+
const { data } = await api.getCustomerRevenue({
|
|
142
|
+
client,
|
|
143
|
+
throwOnError: true,
|
|
144
|
+
path: { customerName: 'c_1' },
|
|
145
|
+
});
|
|
146
|
+
assert.deepEqual(data, { rows: [] });
|
|
147
|
+
assert.equal(seenRequests[0].url, '/api/revenue/c_1/');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('getCustomerRevenue surfaces a 404 and its error', async () => {
|
|
152
|
+
const rejection = { error: 'No revenue for this customer.' };
|
|
153
|
+
await withClientReturning(rejection, 404, async (client) => {
|
|
154
|
+
const { error, response } = await api.getCustomerRevenue({
|
|
155
|
+
client,
|
|
156
|
+
path: { customerName: 'nobody' },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(response.status, 404);
|
|
159
|
+
assert.deepEqual(error, rejection);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('deleteCustomerRevenue parses the deleted count', async () => {
|
|
164
|
+
await withClientReturning({ ok: true, rows_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
165
|
+
const { data } = await api.deleteCustomerRevenue({
|
|
166
|
+
client,
|
|
167
|
+
throwOnError: true,
|
|
168
|
+
path: { customerName: 'c_1' },
|
|
169
|
+
});
|
|
170
|
+
assert.deepEqual(data, { ok: true, rowsDeleted: 1 });
|
|
171
|
+
const request = seenRequests[0];
|
|
172
|
+
assert.equal(request.method, 'DELETE');
|
|
173
|
+
assert.equal(request.url, '/api/revenue/c_1/');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Key metrics ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
test('setKeyMetrics posts the rows in snake_case and parses the booked count', async () => {
|
|
180
|
+
await withClientReturning({ ok: true, rows_inserted: 1 }, 200, async (client, seenRequests) => {
|
|
181
|
+
const rows = [
|
|
182
|
+
{ userName: 'u_1', metricName: 'Meetings booked', amount: '3', month: '2026-06' },
|
|
183
|
+
];
|
|
184
|
+
const { data } = await api.setKeyMetrics({ client, throwOnError: true, body: { rows } });
|
|
185
|
+
assert.deepEqual(data, { ok: true, rowsInserted: 1 });
|
|
186
|
+
const request = seenRequests[0];
|
|
187
|
+
assert.equal(request.method, 'POST');
|
|
188
|
+
assert.equal(request.url, '/api/key-metrics/');
|
|
189
|
+
assert.deepEqual(request.body, {
|
|
190
|
+
rows: [{ user_name: 'u_1', metric_name: 'Meetings booked', amount: '3', month: '2026-06' }],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('getAllKeyMetrics parses the returned rows into camelCase', async () => {
|
|
196
|
+
const wireRow = {
|
|
197
|
+
user_name: 'u_1',
|
|
198
|
+
metric_name: 'Meetings booked',
|
|
199
|
+
month: '2026-06',
|
|
200
|
+
amount: '3.00000000',
|
|
201
|
+
source: 'api',
|
|
202
|
+
};
|
|
203
|
+
await withClientReturning({ rows: [wireRow] }, 200, async (client) => {
|
|
204
|
+
const { data } = await api.getAllKeyMetrics({ client, throwOnError: true });
|
|
205
|
+
assert.deepEqual(data.rows, [
|
|
206
|
+
{
|
|
207
|
+
userName: 'u_1',
|
|
208
|
+
metricName: 'Meetings booked',
|
|
209
|
+
month: '2026-06',
|
|
210
|
+
amount: '3.00000000',
|
|
211
|
+
source: 'api',
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('getUserKeyMetrics requests the user path', async () => {
|
|
218
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
219
|
+
const { data } = await api.getUserKeyMetrics({
|
|
220
|
+
client,
|
|
221
|
+
throwOnError: true,
|
|
222
|
+
path: { userName: 'u_1' },
|
|
223
|
+
});
|
|
224
|
+
assert.deepEqual(data, { rows: [] });
|
|
225
|
+
assert.equal(seenRequests[0].url, '/api/key-metrics/u_1/');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('setKeyMetrics surfaces a 422 and its details', async () => {
|
|
230
|
+
const rejection = {
|
|
231
|
+
error: 'Invalid payload.',
|
|
232
|
+
detail: [{ field: 'rows.0.metric_name', error: 'Unknown metric.' }],
|
|
233
|
+
};
|
|
234
|
+
await withClientReturning(rejection, 422, async (client) => {
|
|
235
|
+
const { error, response } = await api.setKeyMetrics({ client, body: { rows: [] } });
|
|
236
|
+
assert.equal(response.status, 422);
|
|
237
|
+
assert.deepEqual(error, rejection);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('deleteUserKeyMetrics parses the deleted count', async () => {
|
|
242
|
+
await withClientReturning({ ok: true, rows_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
243
|
+
const { data } = await api.deleteUserKeyMetrics({
|
|
244
|
+
client,
|
|
245
|
+
throwOnError: true,
|
|
246
|
+
path: { userName: 'u_1' },
|
|
247
|
+
});
|
|
248
|
+
assert.deepEqual(data, { ok: true, rowsDeleted: 1 });
|
|
249
|
+
const request = seenRequests[0];
|
|
250
|
+
assert.equal(request.method, 'DELETE');
|
|
251
|
+
assert.equal(request.url, '/api/key-metrics/u_1/');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Customer tags ─────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
test('setCustomersTags posts the customers in snake_case and parses the updated count', async () => {
|
|
258
|
+
await withClientReturning(
|
|
259
|
+
{ ok: true, customers_updated: 1 },
|
|
260
|
+
200,
|
|
261
|
+
async (client, seenRequests) => {
|
|
262
|
+
const customers = [{ customerName: 'c_1', tags: { plan: 'pro' } }];
|
|
263
|
+
const { data } = await api.setCustomersTags({
|
|
264
|
+
client,
|
|
265
|
+
throwOnError: true,
|
|
266
|
+
body: { customers },
|
|
267
|
+
});
|
|
268
|
+
assert.deepEqual(data, { ok: true, customersUpdated: 1 });
|
|
269
|
+
const request = seenRequests[0];
|
|
270
|
+
assert.equal(request.method, 'POST');
|
|
271
|
+
assert.equal(request.url, '/api/customer-tags/');
|
|
272
|
+
assert.deepEqual(request.body, {
|
|
273
|
+
customers: [{ customer_name: 'c_1', tags: { plan: 'pro' } }],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('setCustomersTags leaves tag keys untouched, even when they contain underscores', async () => {
|
|
280
|
+
await withClientReturning(
|
|
281
|
+
{ ok: true, customers_updated: 1 },
|
|
282
|
+
200,
|
|
283
|
+
async (client, seenRequests) => {
|
|
284
|
+
const customers = [{ customerName: 'c_1', tags: { utm_source: 'ads', plan_tier: 'pro' } }];
|
|
285
|
+
await api.setCustomersTags({ client, throwOnError: true, body: { customers } });
|
|
286
|
+
assert.deepEqual(seenRequests[0].body, {
|
|
287
|
+
customers: [{ customer_name: 'c_1', tags: { utm_source: 'ads', plan_tier: 'pro' } }],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('getAllCustomersTags parses the returned customers into camelCase', async () => {
|
|
294
|
+
const wireCustomer = { customer_name: 'c_1', tags: { plan: 'pro' } };
|
|
295
|
+
await withClientReturning(
|
|
296
|
+
{ customers: [wireCustomer], next_cursor: null, has_more: false },
|
|
297
|
+
200,
|
|
298
|
+
async (client) => {
|
|
299
|
+
const { data } = await api.getAllCustomersTags({ client, throwOnError: true });
|
|
300
|
+
assert.deepEqual(data.customers, [{ customerName: 'c_1', tags: { plan: 'pro' } }]);
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('getCustomerTags parses the customer tags, leaving tag keys untouched', async () => {
|
|
306
|
+
const wireTags = { customer_name: 'c_1', tags: { plan: 'pro', utm_source: 'ads' } };
|
|
307
|
+
await withClientReturning(wireTags, 200, async (client, seenRequests) => {
|
|
308
|
+
const { data } = await api.getCustomerTags({
|
|
309
|
+
client,
|
|
310
|
+
throwOnError: true,
|
|
311
|
+
path: { customerName: 'c_1' },
|
|
312
|
+
});
|
|
313
|
+
assert.deepEqual(data, { customerName: 'c_1', tags: { plan: 'pro', utm_source: 'ads' } });
|
|
314
|
+
assert.equal(seenRequests[0].url, '/api/customer-tags/c_1/');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('deleteCustomerTags parses the deleted count', async () => {
|
|
319
|
+
await withClientReturning({ ok: true, tags_deleted: 1 }, 200, async (client, seenRequests) => {
|
|
320
|
+
const { data } = await api.deleteCustomerTags({
|
|
321
|
+
client,
|
|
322
|
+
throwOnError: true,
|
|
323
|
+
path: { customerName: 'c_1' },
|
|
324
|
+
});
|
|
325
|
+
assert.deepEqual(data, { ok: true, tagsDeleted: 1 });
|
|
326
|
+
const request = seenRequests[0];
|
|
327
|
+
assert.equal(request.method, 'DELETE');
|
|
328
|
+
assert.equal(request.url, '/api/customer-tags/c_1/');
|
|
329
|
+
});
|
|
330
|
+
});
|
package/tests/index.test.js
CHANGED
|
@@ -405,7 +405,7 @@ test('propagateMetadata writes structured fields and extra tags', async () => {
|
|
|
405
405
|
await weflayr.propagateMetadata(
|
|
406
406
|
{
|
|
407
407
|
featureName: 'onboarding',
|
|
408
|
-
|
|
408
|
+
customerName: '#3756',
|
|
409
409
|
extraTags: { variant: 'control' },
|
|
410
410
|
},
|
|
411
411
|
async () => {
|
|
@@ -419,7 +419,7 @@ test('propagateMetadata writes structured fields and extra tags', async () => {
|
|
|
419
419
|
assert.equal(exporter.exported.length, 1);
|
|
420
420
|
const attrs = exporter.exported[0].attributes;
|
|
421
421
|
assert.equal(attrs['weflayr.feature_name'], 'onboarding');
|
|
422
|
-
assert.equal(attrs['weflayr.
|
|
422
|
+
assert.equal(attrs['weflayr.customer_name'], '#3756');
|
|
423
423
|
assert.equal(attrs['weflayr.tag.variant'], 'control');
|
|
424
424
|
assert.ok(attrs['weflayr.uuid']);
|
|
425
425
|
assert.equal(attrs['weflayr.provider_name'], undefined);
|
|
@@ -429,7 +429,7 @@ test('propagateMetadata writes structured fields and extra tags', async () => {
|
|
|
429
429
|
test('propagateMetadata uuid is stable within scope and changes across calls', async () => {
|
|
430
430
|
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
431
431
|
|
|
432
|
-
await weflayr.propagateMetadata({ featureName: 'f',
|
|
432
|
+
await weflayr.propagateMetadata({ featureName: 'f', customerName: 'c' }, async () => {
|
|
433
433
|
const spanA = tracer.startSpan('a');
|
|
434
434
|
spanA.setAttribute('gen_ai.system', 'openai');
|
|
435
435
|
spanA.end();
|
|
@@ -438,7 +438,7 @@ test('propagateMetadata uuid is stable within scope and changes across calls', a
|
|
|
438
438
|
spanB.end();
|
|
439
439
|
});
|
|
440
440
|
|
|
441
|
-
await weflayr.propagateMetadata({ featureName: 'f',
|
|
441
|
+
await weflayr.propagateMetadata({ featureName: 'f', customerName: 'c' }, async () => {
|
|
442
442
|
const spanC = tracer.startSpan('c_span');
|
|
443
443
|
spanC.setAttribute('gen_ai.system', 'openai');
|
|
444
444
|
spanC.end();
|
|
@@ -459,14 +459,14 @@ test('propagateMetadata nested scopes inner overrides and extra tags merge', asy
|
|
|
459
459
|
await weflayr.propagateMetadata(
|
|
460
460
|
{
|
|
461
461
|
featureName: 'outer',
|
|
462
|
-
|
|
462
|
+
customerName: 'outer_c',
|
|
463
463
|
extraTags: { variant: 'A', shared: 'outer' },
|
|
464
464
|
},
|
|
465
465
|
async () => {
|
|
466
466
|
await weflayr.propagateMetadata(
|
|
467
467
|
{
|
|
468
468
|
featureName: 'inner',
|
|
469
|
-
|
|
469
|
+
customerName: 'inner_c',
|
|
470
470
|
extraTags: { shared: 'inner', experiment: 'B' },
|
|
471
471
|
},
|
|
472
472
|
async () => {
|
|
@@ -481,7 +481,7 @@ test('propagateMetadata nested scopes inner overrides and extra tags merge', asy
|
|
|
481
481
|
await proc.forceFlush();
|
|
482
482
|
const attrs = exporter.exported[0].attributes;
|
|
483
483
|
assert.equal(attrs['weflayr.feature_name'], 'inner');
|
|
484
|
-
assert.equal(attrs['weflayr.
|
|
484
|
+
assert.equal(attrs['weflayr.customer_name'], 'inner_c');
|
|
485
485
|
// Outer extra tag not shadowed survives
|
|
486
486
|
assert.equal(attrs['weflayr.tag.variant'], 'A');
|
|
487
487
|
// Inner extra tag wins on conflict
|
|
@@ -493,7 +493,7 @@ test('propagateMetadata nested scopes inner overrides and extra tags merge', asy
|
|
|
493
493
|
test('propagateMetadata decorator mints fresh uuid per invocation', async () => {
|
|
494
494
|
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
495
495
|
|
|
496
|
-
const wrap = weflayr.propagateMetadata({ featureName: 'f',
|
|
496
|
+
const wrap = weflayr.propagateMetadata({ featureName: 'f', customerName: 'c' });
|
|
497
497
|
const leaf = wrap(async function leaf() {
|
|
498
498
|
const span = tracer.startSpan('child');
|
|
499
499
|
span.setAttribute('gen_ai.system', 'openai');
|
|
@@ -515,7 +515,7 @@ test('propagateMetadata decorator form wraps a function', async () => {
|
|
|
515
515
|
|
|
516
516
|
const wrap = weflayr.propagateMetadata({
|
|
517
517
|
featureName: 'chat',
|
|
518
|
-
|
|
518
|
+
customerName: 'c_42',
|
|
519
519
|
extraTags: { experiment: 'B' },
|
|
520
520
|
});
|
|
521
521
|
const leaf = wrap(async function leaf() {
|
|
@@ -528,7 +528,7 @@ test('propagateMetadata decorator form wraps a function', async () => {
|
|
|
528
528
|
await proc.forceFlush();
|
|
529
529
|
const attrs = exporter.exported[0].attributes;
|
|
530
530
|
assert.equal(attrs['weflayr.feature_name'], 'chat');
|
|
531
|
-
assert.equal(attrs['weflayr.
|
|
531
|
+
assert.equal(attrs['weflayr.customer_name'], 'c_42');
|
|
532
532
|
assert.equal(attrs['weflayr.tag.experiment'], 'B');
|
|
533
533
|
await provider.shutdown();
|
|
534
534
|
});
|
|
@@ -539,7 +539,7 @@ test('propagateMetadata callable form runs function', async () => {
|
|
|
539
539
|
const result = await weflayr.propagateMetadata(
|
|
540
540
|
{
|
|
541
541
|
featureName: 'callable',
|
|
542
|
-
|
|
542
|
+
customerName: 'c_42',
|
|
543
543
|
extraTags: { variant: 'A' },
|
|
544
544
|
},
|
|
545
545
|
async () => {
|
|
@@ -564,7 +564,7 @@ test('propagateMetadata providerName writes attribute', async () => {
|
|
|
564
564
|
await weflayr.propagateMetadata(
|
|
565
565
|
{
|
|
566
566
|
featureName: 'f',
|
|
567
|
-
|
|
567
|
+
customerName: 'c',
|
|
568
568
|
providerName: weflayr.AIProviderName.VERCEL_AI_GATEWAY,
|
|
569
569
|
},
|
|
570
570
|
async () => {
|
|
@@ -579,10 +579,34 @@ test('propagateMetadata providerName writes attribute', async () => {
|
|
|
579
579
|
await provider.shutdown();
|
|
580
580
|
});
|
|
581
581
|
|
|
582
|
-
test('propagateMetadata requires featureName and
|
|
583
|
-
assert.throws(() => weflayr.propagateMetadata({}), /featureName.*
|
|
582
|
+
test('propagateMetadata requires featureName and customerName', () => {
|
|
583
|
+
assert.throws(() => weflayr.propagateMetadata({}), /featureName.*customerName.*required/);
|
|
584
584
|
assert.throws(() => weflayr.propagateMetadata({ featureName: 'f' }), /required/);
|
|
585
|
-
assert.throws(() => weflayr.propagateMetadata({
|
|
585
|
+
assert.throws(() => weflayr.propagateMetadata({ customerName: 'c' }), /required/);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('propagateMetadata deprecated customerId still works', async () => {
|
|
589
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
590
|
+
|
|
591
|
+
const warnings = [];
|
|
592
|
+
const onWarning = (warning) => warnings.push(warning);
|
|
593
|
+
process.on('warning', onWarning);
|
|
594
|
+
|
|
595
|
+
await weflayr.propagateMetadata({ featureName: 'f', customerId: 'Acme Corp' }, async () => {
|
|
596
|
+
const span = tracer.startSpan('child');
|
|
597
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
598
|
+
span.end();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// process.emitWarning delivers on a later tick; drain it before asserting.
|
|
602
|
+
await new Promise((resolve) => process.nextTick(resolve));
|
|
603
|
+
process.removeListener('warning', onWarning);
|
|
604
|
+
assert.ok(warnings.some((warning) => /customerId.*deprecated/.test(warning.message)));
|
|
605
|
+
|
|
606
|
+
await proc.forceFlush();
|
|
607
|
+
const attrs = exporter.exported[0].attributes;
|
|
608
|
+
assert.equal(attrs['weflayr.customer_name'], 'Acme Corp');
|
|
609
|
+
await provider.shutdown();
|
|
586
610
|
});
|
|
587
611
|
|
|
588
612
|
// ── autoInstrument ────────────────────────────────────────────────────────────
|
|
@@ -688,6 +712,15 @@ test('autoInstrument throws when no apiKey is reachable (env nor argument)', asy
|
|
|
688
712
|
}
|
|
689
713
|
});
|
|
690
714
|
|
|
715
|
+
test('autoInstrument with an empty aiSdks list still runs setup', async () => {
|
|
716
|
+
// The vercel instrumentor's ai <= 6 error message tells users to keep their
|
|
717
|
+
// autoInstrument call with 'vercel_ai_gateway' removed (possibly leaving an
|
|
718
|
+
// empty list) — that must still install the processor.
|
|
719
|
+
_state.processor = null;
|
|
720
|
+
await weflayr.autoInstrument({ aiSdks: [], apiKey: 'empty-list-key' });
|
|
721
|
+
assert.ok(_state.processor, 'processor should be installed by the embedded setup() call');
|
|
722
|
+
});
|
|
723
|
+
|
|
691
724
|
test('autoInstrument runs setup when apiKey passed', async () => {
|
|
692
725
|
_state.processor = null;
|
|
693
726
|
class FakeOpenAIInstrumentation {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
VercelAiSdkInstrumentor,
|
|
8
|
+
} = require('../../../src/instrumentation/vercel/full_instrumentation');
|
|
9
|
+
|
|
10
|
+
// The `ai` / `@ai-sdk/otel` packages are app dependencies (not installed in
|
|
11
|
+
// this repo), so instrument() must fail with an actionable install message.
|
|
12
|
+
// The success path (registerTelemetry wiring) is covered by the vercel e2e
|
|
13
|
+
// ingestion scenario.
|
|
14
|
+
test('instrument() without the ai packages installed throws an install hint', () => {
|
|
15
|
+
assert.throws(() => new VercelAiSdkInstrumentor().instrument(), /npm install ai @ai-sdk\/otel/);
|
|
16
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Vercel AI SDK emits aggregate spans around each model call (see
|
|
4
|
+
// src/otel/vercel-ai-sdk-span-filter.js). These tests drive real spans through
|
|
5
|
+
// the WeflayrSpanProcessor and assert exactly one span per model call is
|
|
6
|
+
// exported, for both the ai >= 7 semconv shape and the legacy ai.* shape.
|
|
7
|
+
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { context, trace } = require('@opentelemetry/api');
|
|
11
|
+
const { BasicTracerProvider, InMemorySpanExporter } = require('@opentelemetry/sdk-trace-base');
|
|
12
|
+
|
|
13
|
+
const { WeflayrSpanProcessor } = require('../../src/otel/span-processor');
|
|
14
|
+
|
|
15
|
+
function buildProviderWithExporter() {
|
|
16
|
+
const exporter = new InMemorySpanExporter();
|
|
17
|
+
const provider = new BasicTracerProvider({
|
|
18
|
+
spanProcessors: [new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' })],
|
|
19
|
+
});
|
|
20
|
+
return { exporter, provider };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function startChildSpan(tracer, parentSpan, name, attributes) {
|
|
24
|
+
const parentContext = trace.setSpan(context.active(), parentSpan);
|
|
25
|
+
return tracer.startSpan(name, { attributes }, parentContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('semconv shape: only the chat span is exported, not invoke_agent/step/tool', () => {
|
|
29
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
30
|
+
const tracer = provider.getTracer('gen_ai');
|
|
31
|
+
|
|
32
|
+
const invokeAgent = tracer.startSpan('invoke_agent openai/gpt-4o-mini', {
|
|
33
|
+
attributes: {
|
|
34
|
+
'gen_ai.operation.name': 'invoke_agent',
|
|
35
|
+
'gen_ai.usage.input_tokens': 50,
|
|
36
|
+
'gen_ai.usage.output_tokens': 15,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const step = startChildSpan(tracer, invokeAgent, 'step 1', {
|
|
40
|
+
'gen_ai.operation.name': 'agent_step',
|
|
41
|
+
});
|
|
42
|
+
const chat = startChildSpan(tracer, step, 'chat openai/gpt-4o-mini', {
|
|
43
|
+
'gen_ai.operation.name': 'chat',
|
|
44
|
+
'gen_ai.usage.input_tokens': 50,
|
|
45
|
+
'gen_ai.usage.output_tokens': 15,
|
|
46
|
+
});
|
|
47
|
+
const tool = startChildSpan(tracer, step, 'execute_tool get_weather', {
|
|
48
|
+
'gen_ai.operation.name': 'execute_tool',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
chat.end();
|
|
52
|
+
tool.end();
|
|
53
|
+
step.end();
|
|
54
|
+
invokeAgent.end();
|
|
55
|
+
|
|
56
|
+
const exported = exporter.getFinishedSpans();
|
|
57
|
+
assert.equal(exported.length, 1);
|
|
58
|
+
assert.equal(exported[0].name, 'chat openai/gpt-4o-mini');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('semconv shape: nested embeddings span is dropped, the outer one is exported', () => {
|
|
62
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
63
|
+
const tracer = provider.getTracer('gen_ai');
|
|
64
|
+
|
|
65
|
+
const outerEmbeddings = tracer.startSpan('embeddings openai/text-embedding-3-small', {
|
|
66
|
+
attributes: {
|
|
67
|
+
'gen_ai.operation.name': 'embeddings',
|
|
68
|
+
'gen_ai.usage.input_tokens': 2,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const modelCallEmbeddings = startChildSpan(
|
|
72
|
+
tracer,
|
|
73
|
+
outerEmbeddings,
|
|
74
|
+
'embeddings openai/text-embedding-3-small',
|
|
75
|
+
{
|
|
76
|
+
'gen_ai.operation.name': 'embeddings',
|
|
77
|
+
'gen_ai.usage.input_tokens': 2,
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
modelCallEmbeddings.end();
|
|
82
|
+
outerEmbeddings.end();
|
|
83
|
+
|
|
84
|
+
const exported = exporter.getFinishedSpans();
|
|
85
|
+
assert.equal(exported.length, 1);
|
|
86
|
+
assert.equal(exported[0].spanContext().spanId, outerEmbeddings.spanContext().spanId);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('legacy shape: only ai.<fn>.do* spans are exported', () => {
|
|
90
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
91
|
+
const tracer = provider.getTracer('ai');
|
|
92
|
+
|
|
93
|
+
const outer = tracer.startSpan('ai.streamText', {
|
|
94
|
+
attributes: { 'ai.usage.inputTokens': 100 },
|
|
95
|
+
});
|
|
96
|
+
const doStream = startChildSpan(tracer, outer, 'ai.streamText.doStream', {
|
|
97
|
+
'gen_ai.usage.input_tokens': 100,
|
|
98
|
+
});
|
|
99
|
+
const toolCall = startChildSpan(tracer, outer, 'ai.toolCall', {});
|
|
100
|
+
const doEmbed = tracer.startSpan('ai.embed.doEmbed', {
|
|
101
|
+
attributes: { 'gen_ai.usage.input_tokens': 1 },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
doStream.end();
|
|
105
|
+
toolCall.end();
|
|
106
|
+
doEmbed.end();
|
|
107
|
+
outer.end();
|
|
108
|
+
|
|
109
|
+
const exported = exporter.getFinishedSpans().map((span) => span.name);
|
|
110
|
+
assert.deepEqual(exported.sort(), ['ai.embed.doEmbed', 'ai.streamText.doStream']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('gateway provider id gets the weflayr override, direct providers do not', () => {
|
|
114
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
115
|
+
const tracer = provider.getTracer('gen_ai');
|
|
116
|
+
|
|
117
|
+
const gatewayChat = tracer.startSpan('chat openai/gpt-4o-mini', {
|
|
118
|
+
attributes: { 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'gateway' },
|
|
119
|
+
});
|
|
120
|
+
gatewayChat.end();
|
|
121
|
+
const directChat = tracer.startSpan('chat gpt-4o-mini', {
|
|
122
|
+
attributes: { 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'openai' },
|
|
123
|
+
});
|
|
124
|
+
directChat.end();
|
|
125
|
+
|
|
126
|
+
const [gatewayExported, directExported] = exporter.getFinishedSpans();
|
|
127
|
+
assert.equal(gatewayExported.attributes['weflayr.provider_name'], 'vercel_ai_gateway');
|
|
128
|
+
assert.equal(directExported.attributes['weflayr.provider_name'], undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('gateway provider id does not overwrite a propagateMetadata providerName', () => {
|
|
132
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
133
|
+
const tracer = provider.getTracer('gen_ai');
|
|
134
|
+
|
|
135
|
+
const chat = tracer.startSpan('chat openai/gpt-4o-mini', {
|
|
136
|
+
attributes: {
|
|
137
|
+
'gen_ai.operation.name': 'chat',
|
|
138
|
+
'gen_ai.provider.name': 'gateway',
|
|
139
|
+
'weflayr.provider_name': 'openai',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
chat.end();
|
|
143
|
+
|
|
144
|
+
assert.equal(exporter.getFinishedSpans()[0].attributes['weflayr.provider_name'], 'openai');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('non-Vercel scopes still go through the default export filter', () => {
|
|
148
|
+
const { exporter, provider } = buildProviderWithExporter();
|
|
149
|
+
|
|
150
|
+
const llmSpan = provider
|
|
151
|
+
.getTracer('@traceloop/instrumentation-openai')
|
|
152
|
+
.startSpan('openai.chat', { attributes: { 'gen_ai.system': 'openai' } });
|
|
153
|
+
llmSpan.end();
|
|
154
|
+
const unrelatedSpan = provider.getTracer('my-app').startSpan('http request');
|
|
155
|
+
unrelatedSpan.end();
|
|
156
|
+
|
|
157
|
+
const exported = exporter.getFinishedSpans().map((span) => span.name);
|
|
158
|
+
assert.deepEqual(exported, ['openai.chat']);
|
|
159
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
// First TypeScript module of the SDK: only src/api is compiled (to dist/);
|
|
3
|
+
// the rest of src stays plain JavaScript until it is migrated.
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "NodeNext",
|
|
8
|
+
"moduleResolution": "NodeNext",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/api"]
|
|
18
|
+
}
|