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.
Files changed (42) hide show
  1. package/dist/api/generated/facade.gen.d.ts +102 -0
  2. package/dist/api/generated/facade.gen.js +8 -1
  3. package/dist/api/generated/index.d.ts +2 -2
  4. package/dist/api/generated/index.js +8 -1
  5. package/dist/api/generated/sdk.gen.d.ts +29 -1
  6. package/dist/api/generated/sdk.gen.js +64 -1
  7. package/dist/api/generated/types.gen.d.ts +356 -3
  8. package/index.d.ts +5 -2
  9. package/package.json +1 -1
  10. package/src/api/generated/facade.gen.ts +42 -0
  11. package/src/api/generated/index.ts +2 -2
  12. package/src/api/generated/sdk.gen.ts +64 -1
  13. package/src/api/generated/types.gen.ts +400 -3
  14. package/src/auto-instrument.js +1 -0
  15. package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +11 -0
  16. package/src/instrumentation/anthropic/get_cache_config_telemetry.js +90 -0
  17. package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +6 -1
  18. package/src/instrumentation/anthropic/set_telemetry_attributes.js +2 -0
  19. package/src/instrumentation/bedrock/get_cache_config_telemetry.js +106 -0
  20. package/src/instrumentation/bedrock/get_cache_telemetry.js +11 -6
  21. package/src/instrumentation/bedrock/set_telemetry_attributes.js +6 -2
  22. package/src/instrumentation/google/get_cache_operations_telemetry.js +171 -0
  23. package/src/instrumentation/google/get_cache_telemetry.js +16 -2
  24. package/src/instrumentation/google/set_telemetry_attributes.js +8 -2
  25. package/src/instrumentation/openai/get_cache_config_telemetry.js +63 -0
  26. package/src/instrumentation/openai/set_telemetry_attributes.js +2 -0
  27. package/src/instrumentation/vercel/full_instrumentation.js +10 -2
  28. package/src/instrumentation/vercel/get_cache_creation_telemetry.js +70 -0
  29. package/src/otel/propagation.js +22 -9
  30. package/src/otel/span-filter.js +12 -12
  31. package/src/otel/span-processor.js +8 -0
  32. package/src/otel/vercel-ai-sdk-span-filter.js +1 -1
  33. package/tests/api/index.test.js +76 -0
  34. package/tests/index.test.js +67 -7
  35. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +23 -0
  36. package/tests/instrumentation/anthropic/get_cache_config_telemetry.test.js +92 -0
  37. package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +15 -0
  38. package/tests/instrumentation/bedrock/get_cache_config_telemetry.test.js +107 -0
  39. package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +16 -0
  40. package/tests/instrumentation/google/get_cache_operations_telemetry.test.js +130 -0
  41. package/tests/instrumentation/openai/get_cache_config_telemetry.test.js +83 -0
  42. package/tests/instrumentation/vercel/get_cache_creation_telemetry.test.js +96 -0
@@ -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
- 'weflayr.instrumentation.elevenlabs',
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 isGenAISpan(span) || isKnownLLMInstrumentor(span);
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 providerName set through propagateMetadata was stamped at span start and
68
+ // A providerNameOverride set through propagateMetadata was stamped at span start and
69
69
  // wins.
70
70
  function stampVercelGatewayProviderName(span) {
71
71
  if (
@@ -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 () => {
@@ -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 with gen_ai.* attributes', () => {
237
+ test('isDefaultExportSpan accepts spans from known instrumentation scopes', () => {
238
238
  const span = makeFakeSpan({
239
- attributes: { 'gen_ai.system': 'openai' },
240
- instrumentationScope: { name: 'unknown' },
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 spans from known instrumentation scopes', () => {
245
+ test('isDefaultExportSpan accepts weflayr instrumentation spans', () => {
246
246
  const span = makeFakeSpan({
247
247
  attributes: {},
248
- instrumentationScope: { name: 'opentelemetry.instrumentation.openai' },
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('test');
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 providerName writes attribute', async () => {
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);