weflayr 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/generated/facade.gen.d.ts +102 -0
- package/dist/api/generated/facade.gen.js +8 -1
- package/dist/api/generated/index.d.ts +2 -2
- package/dist/api/generated/index.js +8 -1
- package/dist/api/generated/sdk.gen.d.ts +29 -1
- package/dist/api/generated/sdk.gen.js +64 -1
- package/dist/api/generated/types.gen.d.ts +356 -3
- package/index.d.ts +5 -2
- package/package.json +1 -1
- package/src/api/generated/facade.gen.ts +42 -0
- package/src/api/generated/index.ts +2 -2
- package/src/api/generated/sdk.gen.ts +64 -1
- package/src/api/generated/types.gen.ts +400 -3
- package/src/auto-instrument.js +1 -0
- package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +11 -0
- package/src/instrumentation/anthropic/get_cache_config_telemetry.js +90 -0
- package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +6 -1
- package/src/instrumentation/anthropic/set_telemetry_attributes.js +2 -0
- package/src/instrumentation/bedrock/get_cache_config_telemetry.js +106 -0
- package/src/instrumentation/bedrock/get_cache_telemetry.js +11 -6
- package/src/instrumentation/bedrock/set_telemetry_attributes.js +6 -2
- package/src/instrumentation/google/get_cache_operations_telemetry.js +171 -0
- package/src/instrumentation/google/get_cache_telemetry.js +16 -2
- package/src/instrumentation/google/set_telemetry_attributes.js +8 -2
- package/src/instrumentation/openai/get_cache_config_telemetry.js +63 -0
- package/src/instrumentation/openai/set_telemetry_attributes.js +2 -0
- package/src/instrumentation/vercel/full_instrumentation.js +10 -2
- package/src/instrumentation/vercel/get_cache_creation_telemetry.js +70 -0
- package/src/otel/propagation.js +22 -9
- package/src/otel/span-filter.js +12 -12
- package/src/otel/span-processor.js +8 -0
- package/src/otel/vercel-ai-sdk-span-filter.js +1 -1
- package/tests/api/index.test.js +76 -0
- package/tests/index.test.js +67 -7
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +23 -0
- package/tests/instrumentation/anthropic/get_cache_config_telemetry.test.js +92 -0
- package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +15 -0
- package/tests/instrumentation/bedrock/get_cache_config_telemetry.test.js +107 -0
- package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +16 -0
- package/tests/instrumentation/google/get_cache_operations_telemetry.test.js +130 -0
- package/tests/instrumentation/openai/get_cache_config_telemetry.test.js +83 -0
- package/tests/instrumentation/vercel/get_cache_creation_telemetry.test.js +96 -0
package/src/otel/span-filter.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// - Translated from TypeScript to JavaScript
|
|
8
8
|
// - Dropped the LANGFUSE_TRACER_NAME special case (we have no SDK-internal tracer)
|
|
9
9
|
// - Dropped isLangfuseSpan
|
|
10
|
+
// - Export only spans from known LLM instrumentation scopes: general-purpose
|
|
11
|
+
// APM SDKs (Sentry, Datadog, ...) also instrument LLM calls with gen_ai.*
|
|
12
|
+
// attributes, so the original "any span with gen_ai.* attributes" check
|
|
13
|
+
// exported the same call twice
|
|
10
14
|
//
|
|
11
15
|
// See LICENSE-MIT in the package root for the full license text.
|
|
12
16
|
|
|
@@ -15,19 +19,24 @@
|
|
|
15
19
|
const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
16
20
|
'agent_framework',
|
|
17
21
|
'ai',
|
|
22
|
+
'gen_ai',
|
|
18
23
|
'haystack',
|
|
19
24
|
'langsmith',
|
|
20
25
|
'litellm',
|
|
26
|
+
'mistralai_sdk_tracer',
|
|
21
27
|
'openinference',
|
|
22
28
|
'opentelemetry.instrumentation.anthropic',
|
|
23
29
|
'opentelemetry.instrumentation.aws_bedrock',
|
|
24
30
|
'opentelemetry.instrumentation.bedrock',
|
|
31
|
+
'opentelemetry.instrumentation.cohere',
|
|
25
32
|
'opentelemetry.instrumentation.gemini',
|
|
26
33
|
'opentelemetry.instrumentation.google_genai',
|
|
27
34
|
'opentelemetry.instrumentation.google_generativeai',
|
|
28
35
|
'opentelemetry.instrumentation.openai',
|
|
29
36
|
'opentelemetry.instrumentation.openai_v2',
|
|
30
|
-
'
|
|
37
|
+
'opentelemetry.instrumentation.vertex_ai',
|
|
38
|
+
'opentelemetry.instrumentation.vertexai',
|
|
39
|
+
'weflayr.instrumentation',
|
|
31
40
|
'strands-agents',
|
|
32
41
|
'vllm',
|
|
33
42
|
'@traceloop/instrumentation-openai',
|
|
@@ -43,27 +52,18 @@ const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
|
43
52
|
'@traceloop/instrumentation-mcp',
|
|
44
53
|
];
|
|
45
54
|
|
|
46
|
-
const EXACT_LLM_INSTRUMENTATION_SCOPES = new Set(['ai']);
|
|
47
|
-
|
|
48
|
-
function isGenAISpan(span) {
|
|
49
|
-
return Object.keys(span.attributes).some((key) => key.startsWith('gen_ai.'));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
55
|
function isKnownLLMInstrumentor(span) {
|
|
53
56
|
const scope = span.instrumentationScope.name;
|
|
54
57
|
return KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.some(
|
|
55
|
-
(prefix) =>
|
|
56
|
-
scope === prefix ||
|
|
57
|
-
(!EXACT_LLM_INSTRUMENTATION_SCOPES.has(prefix) && scope.startsWith(`${prefix}.`))
|
|
58
|
+
(prefix) => scope === prefix || scope.startsWith(`${prefix}.`)
|
|
58
59
|
);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
function isDefaultExportSpan(span) {
|
|
62
|
-
return
|
|
63
|
+
return isKnownLLMInstrumentor(span);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
module.exports = {
|
|
66
|
-
isGenAISpan,
|
|
67
67
|
isKnownLLMInstrumentor,
|
|
68
68
|
isDefaultExportSpan,
|
|
69
69
|
};
|
|
@@ -49,6 +49,11 @@ function _isContentAttribute(key) {
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Stamped on every exported span when a `mask` is configured: the mask
|
|
53
|
+
// function's source code, so the stored raw event shows what redaction ran
|
|
54
|
+
// client-side when debugging missing attributes.
|
|
55
|
+
const MASK_FUNCTION_ATTRIBUTE = 'weflayr.mask_function';
|
|
56
|
+
|
|
52
57
|
class WeflayrSpanProcessor {
|
|
53
58
|
constructor({ exporter, exportMode = 'batch', defaultTags = {}, mask, shouldExportSpan } = {}) {
|
|
54
59
|
if (!exporter) {
|
|
@@ -60,6 +65,7 @@ class WeflayrSpanProcessor {
|
|
|
60
65
|
: new BatchSpanProcessor(exporter);
|
|
61
66
|
this._defaultTags = defaultTags;
|
|
62
67
|
this._mask = mask;
|
|
68
|
+
this._maskFunctionSource = mask ? String(mask) : null;
|
|
63
69
|
this._shouldExportSpan = shouldExportSpan ?? isDefaultExportSpan;
|
|
64
70
|
this._vercelAiSdkSpanFilter = new VercelAiSdkSpanFilter();
|
|
65
71
|
// LLM spans we've started but not yet seen end. On forceFlush we end any
|
|
@@ -126,6 +132,8 @@ class WeflayrSpanProcessor {
|
|
|
126
132
|
} catch {
|
|
127
133
|
for (const k of Object.keys(span.attributes)) delete span.attributes[k];
|
|
128
134
|
}
|
|
135
|
+
// Stamped after masking so the mask can't strip it.
|
|
136
|
+
span.attributes[MASK_FUNCTION_ATTRIBUTE] = this._maskFunctionSource;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
return this._delegate.onEnd(span);
|
|
@@ -65,7 +65,7 @@ class VercelAiSdkSpanFilter {
|
|
|
65
65
|
// `@ai-sdk/otel` reports the Vercel AI Gateway as the bare provider id
|
|
66
66
|
// "gateway" — too generic a name to special-case downstream. Stamp the weflayr
|
|
67
67
|
// provider override (which the normalizer prefers) with the canonical name.
|
|
68
|
-
// A
|
|
68
|
+
// A providerNameOverride set through propagateMetadata was stamped at span start and
|
|
69
69
|
// wins.
|
|
70
70
|
function stampVercelGatewayProviderName(span) {
|
|
71
71
|
if (
|
package/tests/api/index.test.js
CHANGED
|
@@ -274,6 +274,82 @@ test('deleteKeyMetrics sends the userNames filter and parses the deleted count',
|
|
|
274
274
|
});
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
// ── Margin ────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
test('getMargin parses the returned rows into camelCase', async () => {
|
|
280
|
+
const wireRow = {
|
|
281
|
+
customer_name: 'c_1',
|
|
282
|
+
revenue: 1000.0,
|
|
283
|
+
cost: 200.0,
|
|
284
|
+
margin: 800.0,
|
|
285
|
+
margin_pct: 80.0,
|
|
286
|
+
profitability_status: 'healthy',
|
|
287
|
+
};
|
|
288
|
+
await withClientReturning(
|
|
289
|
+
{ rows: [wireRow], next_cursor: null, has_more: false },
|
|
290
|
+
200,
|
|
291
|
+
async (client) => {
|
|
292
|
+
const { data } = await api.getMargin({ client, throwOnError: true });
|
|
293
|
+
assert.deepEqual(data.rows, [
|
|
294
|
+
{
|
|
295
|
+
customerName: 'c_1',
|
|
296
|
+
revenue: 1000.0,
|
|
297
|
+
cost: 200.0,
|
|
298
|
+
margin: 800.0,
|
|
299
|
+
marginPct: 80.0,
|
|
300
|
+
profitabilityStatus: 'healthy',
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('getMargin sends the cursor and limit query params', async () => {
|
|
308
|
+
await withClientReturning(
|
|
309
|
+
{ rows: [], next_cursor: 'Y19h', has_more: true },
|
|
310
|
+
200,
|
|
311
|
+
async (client, seenRequests) => {
|
|
312
|
+
const { data } = await api.getMargin({
|
|
313
|
+
client,
|
|
314
|
+
throwOnError: true,
|
|
315
|
+
query: { cursor: 'abc123', limit: 2 },
|
|
316
|
+
});
|
|
317
|
+
assert.equal(data.nextCursor, 'Y19h');
|
|
318
|
+
assert.equal(data.hasMore, true);
|
|
319
|
+
const query = new URL(seenRequests[0].url, 'http://x').searchParams;
|
|
320
|
+
assert.equal(query.get('cursor'), 'abc123');
|
|
321
|
+
assert.equal(query.get('limit'), '2');
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('getMargin sends the customerNames filter as repeated params', async () => {
|
|
327
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
328
|
+
const { data } = await api.getMargin({
|
|
329
|
+
client,
|
|
330
|
+
throwOnError: true,
|
|
331
|
+
query: { customerNames: ['c_1', 'c_2'] },
|
|
332
|
+
});
|
|
333
|
+
assert.deepEqual(data, { rows: [] });
|
|
334
|
+
const query = new URL(seenRequests[0].url, 'http://x').searchParams;
|
|
335
|
+
assert.deepEqual(query.getAll('customer_names'), ['c_1', 'c_2']);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('getMargin sends the dateFrom/dateTo/applyCredits filters in snake_case', async () => {
|
|
340
|
+
await withClientReturning({ rows: [] }, 200, async (client, seenRequests) => {
|
|
341
|
+
await api.getMargin({
|
|
342
|
+
client,
|
|
343
|
+
throwOnError: true,
|
|
344
|
+
query: { dateFrom: '2026-06-01', dateTo: '2026-06-30', applyCredits: true },
|
|
345
|
+
});
|
|
346
|
+
const query = new URL(seenRequests[0].url, 'http://x').searchParams;
|
|
347
|
+
assert.equal(query.get('date_from'), '2026-06-01');
|
|
348
|
+
assert.equal(query.get('date_to'), '2026-06-30');
|
|
349
|
+
assert.equal(query.get('apply_credits'), 'true');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
277
353
|
// ── Customer tags ─────────────────────────────────────────────────────────────
|
|
278
354
|
|
|
279
355
|
test('setCustomersTags posts the customers in snake_case and parses the updated count', async () => {
|
package/tests/index.test.js
CHANGED
|
@@ -234,22 +234,32 @@ test('buildExporter does not warn when warnOnExportError is false', async () =>
|
|
|
234
234
|
|
|
235
235
|
// ── span-filter ───────────────────────────────────────────────────────────────
|
|
236
236
|
|
|
237
|
-
test('isDefaultExportSpan accepts spans
|
|
237
|
+
test('isDefaultExportSpan accepts spans from known instrumentation scopes', () => {
|
|
238
238
|
const span = makeFakeSpan({
|
|
239
|
-
attributes: {
|
|
240
|
-
instrumentationScope: { name: '
|
|
239
|
+
attributes: {},
|
|
240
|
+
instrumentationScope: { name: 'opentelemetry.instrumentation.openai' },
|
|
241
241
|
});
|
|
242
242
|
assert.equal(isDefaultExportSpan(span), true);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
test('isDefaultExportSpan accepts
|
|
245
|
+
test('isDefaultExportSpan accepts weflayr instrumentation spans', () => {
|
|
246
246
|
const span = makeFakeSpan({
|
|
247
247
|
attributes: {},
|
|
248
|
-
instrumentationScope: { name: '
|
|
248
|
+
instrumentationScope: { name: 'weflayr.instrumentation.openai_audio' },
|
|
249
249
|
});
|
|
250
250
|
assert.equal(isDefaultExportSpan(span), true);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
test('isDefaultExportSpan rejects gen_ai spans from unknown scopes', () => {
|
|
254
|
+
// APM SDKs (Sentry, Datadog, ...) also instrument LLM calls with gen_ai.*
|
|
255
|
+
// attributes — exporting them would duplicate the instrumentor's span.
|
|
256
|
+
const span = makeFakeSpan({
|
|
257
|
+
attributes: { 'gen_ai.system': 'openai' },
|
|
258
|
+
instrumentationScope: { name: '@sentry/node' },
|
|
259
|
+
});
|
|
260
|
+
assert.equal(isDefaultExportSpan(span), false);
|
|
261
|
+
});
|
|
262
|
+
|
|
253
263
|
test('isDefaultExportSpan rejects unrelated spans', () => {
|
|
254
264
|
const span = makeFakeSpan({
|
|
255
265
|
attributes: { 'http.method': 'GET' },
|
|
@@ -335,6 +345,26 @@ test('WeflayrSpanProcessor applies mask before export', async () => {
|
|
|
335
345
|
assert.equal(span.attributes['gen_ai.prompt'], '[REDACTED]');
|
|
336
346
|
});
|
|
337
347
|
|
|
348
|
+
test('WeflayrSpanProcessor stamps mask function source on exported spans', async () => {
|
|
349
|
+
const exporter = makeExporterStub();
|
|
350
|
+
const mask = (attrs) => attrs;
|
|
351
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate', mask });
|
|
352
|
+
const span = makeFakeSpan({ attributes: { 'gen_ai.system': 'openai' } });
|
|
353
|
+
proc.onEnd(span);
|
|
354
|
+
await proc.forceFlush();
|
|
355
|
+
const maskFunctionSource = exporter.exported[0].attributes['weflayr.mask_function'];
|
|
356
|
+
assert.ok(maskFunctionSource.includes('(attrs) => attrs'));
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('WeflayrSpanProcessor omits mask function attribute without mask', async () => {
|
|
360
|
+
const exporter = makeExporterStub();
|
|
361
|
+
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
362
|
+
const span = makeFakeSpan({ attributes: { 'gen_ai.system': 'openai' } });
|
|
363
|
+
proc.onEnd(span);
|
|
364
|
+
await proc.forceFlush();
|
|
365
|
+
assert.equal(exporter.exported[0].attributes['weflayr.mask_function'], undefined);
|
|
366
|
+
});
|
|
367
|
+
|
|
338
368
|
test('WeflayrSpanProcessor strips message content when capture disabled', async () => {
|
|
339
369
|
// Instrumentors gate content on their own env vars (openllmetry captures by
|
|
340
370
|
// default), so the processor itself must drop message content when
|
|
@@ -395,7 +425,7 @@ function _makeMetadataHarness() {
|
|
|
395
425
|
const exporter = makeExporterStub();
|
|
396
426
|
const proc = new WeflayrSpanProcessor({ exporter, exportMode: 'immediate' });
|
|
397
427
|
const provider = new BasicTracerProvider({ spanProcessors: [proc] });
|
|
398
|
-
const tracer = provider.getTracer('
|
|
428
|
+
const tracer = provider.getTracer('opentelemetry.instrumentation.openai');
|
|
399
429
|
return { exporter, proc, provider, tracer };
|
|
400
430
|
}
|
|
401
431
|
|
|
@@ -558,9 +588,34 @@ test('propagateMetadata callable form runs function', async () => {
|
|
|
558
588
|
await provider.shutdown();
|
|
559
589
|
});
|
|
560
590
|
|
|
561
|
-
test('propagateMetadata
|
|
591
|
+
test('propagateMetadata providerNameOverride writes attribute', async () => {
|
|
592
|
+
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
593
|
+
|
|
594
|
+
await weflayr.propagateMetadata(
|
|
595
|
+
{
|
|
596
|
+
featureName: 'f',
|
|
597
|
+
customerName: 'c',
|
|
598
|
+
providerNameOverride: weflayr.AIProviderName.VERCEL_AI_GATEWAY,
|
|
599
|
+
},
|
|
600
|
+
async () => {
|
|
601
|
+
const span = tracer.startSpan('child');
|
|
602
|
+
span.setAttribute('gen_ai.system', 'openai');
|
|
603
|
+
span.end();
|
|
604
|
+
}
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
await proc.forceFlush();
|
|
608
|
+
assert.equal(exporter.exported[0].attributes['weflayr.provider_name'], 'vercel_ai_gateway');
|
|
609
|
+
await provider.shutdown();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('propagateMetadata deprecated providerName still works', async () => {
|
|
562
613
|
const { exporter, proc, provider, tracer } = _makeMetadataHarness();
|
|
563
614
|
|
|
615
|
+
const warnings = [];
|
|
616
|
+
const onWarning = (warning) => warnings.push(warning);
|
|
617
|
+
process.on('warning', onWarning);
|
|
618
|
+
|
|
564
619
|
await weflayr.propagateMetadata(
|
|
565
620
|
{
|
|
566
621
|
featureName: 'f',
|
|
@@ -574,6 +629,11 @@ test('propagateMetadata providerName writes attribute', async () => {
|
|
|
574
629
|
}
|
|
575
630
|
);
|
|
576
631
|
|
|
632
|
+
// process.emitWarning delivers on a later tick; drain it before asserting.
|
|
633
|
+
await new Promise((resolve) => process.nextTick(resolve));
|
|
634
|
+
process.removeListener('warning', onWarning);
|
|
635
|
+
assert.ok(warnings.some((warning) => /providerName.*deprecated/.test(warning.message)));
|
|
636
|
+
|
|
577
637
|
await proc.forceFlush();
|
|
578
638
|
assert.equal(exporter.exported[0].attributes['weflayr.provider_name'], 'vercel_ai_gateway');
|
|
579
639
|
await provider.shutdown();
|
|
@@ -63,6 +63,29 @@ test('stamps cache read on a warm call', () => {
|
|
|
63
63
|
assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], undefined);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test('stamps usage.cache_creation verbatim', () => {
|
|
67
|
+
const instrumentation = { _endSpan() {} };
|
|
68
|
+
new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
|
|
69
|
+
|
|
70
|
+
const span = fakeSpan();
|
|
71
|
+
instrumentation._endSpan({
|
|
72
|
+
span,
|
|
73
|
+
type: 'completion',
|
|
74
|
+
result: {
|
|
75
|
+
usage: {
|
|
76
|
+
input_tokens: 10,
|
|
77
|
+
cache_creation_input_tokens: 5300,
|
|
78
|
+
cache_creation: { ephemeral_5m_input_tokens: 4000, ephemeral_1h_input_tokens: 1300 },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(JSON.parse(span.attrs['weflayr.cache.creation']), {
|
|
84
|
+
ephemeral_5m_input_tokens: 4000,
|
|
85
|
+
ephemeral_1h_input_tokens: 1300,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
66
89
|
test('sets nothing for an uncached call', () => {
|
|
67
90
|
const instrumentation = { _endSpan() {} };
|
|
68
91
|
new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the Anthropic request-side cache_control breakpoint capture.
|
|
4
|
+
//
|
|
5
|
+
// The traceloop instrumentation instance is stubbed with a `startSpan` that
|
|
6
|
+
// returns a fake span, so the wrap can be exercised without the real SDK.
|
|
7
|
+
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
AnthropicCacheConfigInstrumentor,
|
|
13
|
+
extractCacheBreakpoints,
|
|
14
|
+
} = require('../../../src/instrumentation/anthropic/get_cache_config_telemetry');
|
|
15
|
+
|
|
16
|
+
function makeFakeSpan() {
|
|
17
|
+
const attributes = {};
|
|
18
|
+
return {
|
|
19
|
+
attributes,
|
|
20
|
+
setAttribute(key, value) {
|
|
21
|
+
attributes[key] = value;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeStubInstrumentation() {
|
|
27
|
+
return {
|
|
28
|
+
startSpan() {
|
|
29
|
+
return makeFakeSpan();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('stamps breakpoints from system, message content and tools', () => {
|
|
35
|
+
const instrumentation = makeStubInstrumentation();
|
|
36
|
+
new AnthropicCacheConfigInstrumentor().instrument(instrumentation);
|
|
37
|
+
|
|
38
|
+
const span = instrumentation.startSpan({
|
|
39
|
+
type: 'chat',
|
|
40
|
+
params: {
|
|
41
|
+
system: [{ type: 'text', text: 'big prefix', cache_control: { type: 'ephemeral' } }],
|
|
42
|
+
messages: [
|
|
43
|
+
{ role: 'user', content: 'plain string content' },
|
|
44
|
+
{
|
|
45
|
+
role: 'user',
|
|
46
|
+
content: [{ type: 'text', text: 'doc', cache_control: { type: 'ephemeral', ttl: '1h' } }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
tools: [
|
|
50
|
+
{ name: 'get_weather', cache_control: { type: 'ephemeral' } },
|
|
51
|
+
{ name: 'uncached_tool' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(JSON.parse(span.attributes['weflayr.cache.breakpoints']), [
|
|
57
|
+
{ location: 'system' },
|
|
58
|
+
{ location: 'messages[1]', ttl: '1h' },
|
|
59
|
+
{ location: 'tools[0]' },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('no attribute without breakpoints', () => {
|
|
64
|
+
const instrumentation = makeStubInstrumentation();
|
|
65
|
+
new AnthropicCacheConfigInstrumentor().instrument(instrumentation);
|
|
66
|
+
|
|
67
|
+
const span = instrumentation.startSpan({
|
|
68
|
+
type: 'chat',
|
|
69
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
assert.ok(!('weflayr.cache.breakpoints' in span.attributes));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('extractCacheBreakpoints omits the ttl when the request sets none', () => {
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
extractCacheBreakpoints({
|
|
78
|
+
system: [{ type: 'text', text: 'p', cache_control: { type: 'ephemeral' } }],
|
|
79
|
+
}),
|
|
80
|
+
[{ location: 'system' }]
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('uninstrument restores the original startSpan', () => {
|
|
85
|
+
const instrumentation = makeStubInstrumentation();
|
|
86
|
+
const original = instrumentation.startSpan;
|
|
87
|
+
const instrumentor = new AnthropicCacheConfigInstrumentor();
|
|
88
|
+
instrumentor.instrument(instrumentation);
|
|
89
|
+
assert.notEqual(instrumentation.startSpan, original);
|
|
90
|
+
instrumentor.uninstrument();
|
|
91
|
+
assert.equal(instrumentation.startSpan, original);
|
|
92
|
+
});
|
|
@@ -52,6 +52,21 @@ test('stamps tool definitions from the request params', () => {
|
|
|
52
52
|
assert.deepEqual(JSON.parse(span.attributes['gen_ai.tool.definitions']), [WEATHER_TOOL]);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('drops cache_control from the stamped tool definitions', () => {
|
|
56
|
+
const instrumentation = makeStubInstrumentation();
|
|
57
|
+
new AnthropicToolDefinitionsInstrumentor().instrument(instrumentation);
|
|
58
|
+
|
|
59
|
+
const span = instrumentation.startSpan({
|
|
60
|
+
type: 'chat',
|
|
61
|
+
params: {
|
|
62
|
+
model: 'claude-haiku-4-5',
|
|
63
|
+
tools: [{ ...WEATHER_TOOL, cache_control: { type: 'ephemeral', ttl: '1h' } }],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(JSON.parse(span.attributes['gen_ai.tool.definitions']), [WEATHER_TOOL]);
|
|
68
|
+
});
|
|
69
|
+
|
|
55
70
|
test('no attribute when the request has no tools', () => {
|
|
56
71
|
const instrumentation = makeStubInstrumentation();
|
|
57
72
|
new AnthropicToolDefinitionsInstrumentor().instrument(instrumentation);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the Bedrock request-side cache breakpoint capture.
|
|
4
|
+
//
|
|
5
|
+
// The extractor reads both body schemas an InvokeModel request can carry:
|
|
6
|
+
// Anthropic-on-Bedrock `cache_control` blocks and Amazon Nova `cachePoint`
|
|
7
|
+
// blocks (attached to a content block or standing alone). The traceloop
|
|
8
|
+
// instrumentation instance is stubbed with a `_startSpan` returning a fake
|
|
9
|
+
// span, so the wrap can be exercised without the real SDK.
|
|
10
|
+
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
BedrockCacheConfigInstrumentor,
|
|
16
|
+
extractBedrockCacheBreakpoints,
|
|
17
|
+
} = require('../../../src/instrumentation/bedrock/get_cache_config_telemetry');
|
|
18
|
+
|
|
19
|
+
function makeFakeSpan() {
|
|
20
|
+
const attributes = {};
|
|
21
|
+
return {
|
|
22
|
+
attributes,
|
|
23
|
+
setAttribute(key, value) {
|
|
24
|
+
attributes[key] = value;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeStubInstrumentation() {
|
|
30
|
+
return {
|
|
31
|
+
_startSpan() {
|
|
32
|
+
return makeFakeSpan();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('extracts nova cache points', () => {
|
|
38
|
+
const body = {
|
|
39
|
+
system: [{ text: 'sys' }, { cachePoint: { type: 'default' } }],
|
|
40
|
+
messages: [
|
|
41
|
+
{
|
|
42
|
+
role: 'user',
|
|
43
|
+
content: [
|
|
44
|
+
{ text: 'big prefix', cachePoint: { type: 'default' } },
|
|
45
|
+
{ text: 'Reply with OK.' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
toolConfig: {
|
|
50
|
+
tools: [
|
|
51
|
+
{ toolSpec: { name: 'get_weather' } },
|
|
52
|
+
{ cachePoint: { type: 'default', ttl: '1h' } },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
assert.deepEqual(extractBedrockCacheBreakpoints(body), [
|
|
57
|
+
{ location: 'system' },
|
|
58
|
+
{ location: 'messages[0]' },
|
|
59
|
+
{ location: 'tools[1]', ttl: '1h' },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('extracts anthropic-on-bedrock cache_control', () => {
|
|
64
|
+
const body = {
|
|
65
|
+
anthropic_version: 'bedrock-2023-05-31',
|
|
66
|
+
system: [{ type: 'text', text: 'big prefix', cache_control: { type: 'ephemeral', ttl: '1h' } }],
|
|
67
|
+
messages: [{ role: 'user', content: 'Reply with OK.' }],
|
|
68
|
+
};
|
|
69
|
+
assert.deepEqual(extractBedrockCacheBreakpoints(body), [{ location: 'system', ttl: '1h' }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no breakpoints without cache blocks', () => {
|
|
73
|
+
assert.deepEqual(
|
|
74
|
+
extractBedrockCacheBreakpoints({ messages: [{ role: 'user', content: 'hello' }] }),
|
|
75
|
+
[]
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('stamps breakpoints from the request body', () => {
|
|
80
|
+
const instrumentation = makeStubInstrumentation();
|
|
81
|
+
new BedrockCacheConfigInstrumentor().instrument(instrumentation);
|
|
82
|
+
|
|
83
|
+
const span = instrumentation._startSpan({
|
|
84
|
+
params: {
|
|
85
|
+
input: {
|
|
86
|
+
modelId: 'amazon.nova-micro-v1:0',
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
messages: [{ role: 'user', content: [{ text: 'p', cachePoint: { type: 'default' } }] }],
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.deepEqual(JSON.parse(span.attributes['weflayr.cache.breakpoints']), [
|
|
95
|
+
{ location: 'messages[0]' },
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('uninstrument restores the original _startSpan', () => {
|
|
100
|
+
const instrumentation = makeStubInstrumentation();
|
|
101
|
+
const original = instrumentation._startSpan;
|
|
102
|
+
const instrumentor = new BedrockCacheConfigInstrumentor();
|
|
103
|
+
instrumentor.instrument(instrumentation);
|
|
104
|
+
assert.notEqual(instrumentation._startSpan, original);
|
|
105
|
+
instrumentor.uninstrument();
|
|
106
|
+
assert.equal(instrumentation._startSpan, original);
|
|
107
|
+
});
|
|
@@ -32,6 +32,22 @@ function passthroughInstrumentation() {
|
|
|
32
32
|
return { _wrapPromise: (_span, promise) => Promise.resolve(promise) };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
test('stamps cache write from an anthropic-on-bedrock usage shape', () => {
|
|
36
|
+
const instrumentation = { _wrapPromise: (span, promise) => promise };
|
|
37
|
+
new BedrockPromptCachingInstrumentor().instrument(instrumentation);
|
|
38
|
+
|
|
39
|
+
const span = fakeSpan();
|
|
40
|
+
const body = new TextEncoder().encode(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
usage: { input_tokens: 10, cache_creation_input_tokens: 5300, cache_read_input_tokens: 0 },
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
return instrumentation._wrapPromise(span, Promise.resolve({ body })).then(() => {
|
|
46
|
+
assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], 5300);
|
|
47
|
+
assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
35
51
|
test('stamps cache read/write from a buffered InvokeModel body', async () => {
|
|
36
52
|
const instrumentation = passthroughInstrumentation();
|
|
37
53
|
new BedrockPromptCachingInstrumentor().instrument(instrumentation);
|