weflayr 0.20.5 → 0.21.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 (38) hide show
  1. package/index.d.ts +2 -0
  2. package/package.json +2 -2
  3. package/src/auto-instrument.js +67 -23
  4. package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +107 -0
  5. package/src/instrumentation/anthropic/set_telemetry_attributes.js +27 -0
  6. package/src/instrumentation/bedrock/get_cache_telemetry.js +104 -0
  7. package/src/instrumentation/bedrock/set_telemetry_attributes.js +27 -0
  8. package/src/instrumentation/cohere/get_chat_telemetry.js +88 -0
  9. package/src/instrumentation/cohere/get_embeddings_telemetry.js +97 -0
  10. package/src/instrumentation/cohere/set_telemetry_attributes.js +32 -0
  11. package/src/instrumentation/elevenlabs/full_instrumentation.js +236 -0
  12. package/src/instrumentation/google/get_audio_telemetry.js +31 -0
  13. package/src/instrumentation/google/get_cache_telemetry.js +27 -0
  14. package/src/instrumentation/google/get_embeddings_telemetry.js +78 -0
  15. package/src/instrumentation/google/get_provider_name.js +25 -0
  16. package/src/instrumentation/google/get_reasoning_telemetry.js +21 -0
  17. package/src/instrumentation/google/set_telemetry_attributes.js +98 -0
  18. package/src/instrumentation/openai/get_audio_telemetry.js +149 -0
  19. package/src/instrumentation/openai/get_cache_and_reasoning_telemetry.js +71 -0
  20. package/src/instrumentation/openai/get_embeddings_telemetry.js +95 -0
  21. package/src/instrumentation/openai/get_streaming_usage.js +81 -0
  22. package/src/instrumentation/openai/set_telemetry_attributes.js +39 -0
  23. package/src/otel/propagation.js +5 -0
  24. package/src/otel/span-filter.js +1 -5
  25. package/tests/index.test.js +42 -44
  26. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +125 -0
  27. package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +102 -0
  28. package/tests/instrumentation/cohere/get_chat_telemetry.test.js +148 -0
  29. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +129 -0
  30. package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +244 -0
  31. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +125 -0
  32. package/tests/instrumentation/google/get_reasoning_telemetry.test.js +41 -0
  33. package/tests/instrumentation/google/set_telemetry_attributes.test.js +187 -0
  34. package/tests/instrumentation/openai/get_audio_telemetry.test.js +170 -0
  35. package/tests/instrumentation/openai/get_cache_and_reasoning_telemetry.test.js +83 -0
  36. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +126 -0
  37. package/tests/instrumentation/openai/get_streaming_usage.test.js +76 -0
  38. package/src/otel/openai-streaming-usage.js +0 -60
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cached-prompt token enrichment complement for the non-streamed OpenAI chat path.
5
+ *
6
+ * `@traceloop/instrumentation-openai` records `gen_ai.usage.{input,output}_tokens`
7
+ * for non-streamed calls but never reads `usage.prompt_tokens_details.cached_tokens`,
8
+ * so spans for prompt-cache hits carry no cached-token count. The Python
9
+ * OpenLLMetry instrumentor sets `gen_ai.usage.cache_read_input_tokens` from that
10
+ * field, so this brings the Node SDK to parity.
11
+ *
12
+ * It wraps the instrumentor instance's `_endSpan({ span, type, result })`, which
13
+ * finalizes the span after the response resolves, letting us stamp the
14
+ * cached-token count off `result.usage` while the span is still open. OpenAI has
15
+ * no separate cache-write count, so only `cache_read_input_tokens` is set.
16
+ *
17
+ * The same hook surfaces the reasoning-token count: reasoning models report it as
18
+ * `usage.completion_tokens_details.reasoning_tokens` (a subset of output_tokens)
19
+ * which the Node instrumentor drops, so this stamps the flat `reasoning_tokens`
20
+ * attribute (the form the Python instrumentor emits and the normalizer reads).
21
+ */
22
+
23
+ const CACHE_READ_INPUT_TOKENS_ATTR = 'gen_ai.usage.cache_read_input_tokens';
24
+ const REASONING_TOKENS_ATTR = 'gen_ai.usage.reasoning_tokens';
25
+
26
+ class OpenAIPromptCachingInstrumentor {
27
+ constructor() {
28
+ // [instrumentation, methodName, originalFunction] for uninstrument().
29
+ this._patched = [];
30
+ }
31
+
32
+ // No-op (and never throws) if the instrumentor's internals have changed shape —
33
+ // the worst case is a missing cached-token count, never a crash.
34
+ instrument(instrumentation) {
35
+ const original = instrumentation && instrumentation._endSpan;
36
+ if (typeof original !== 'function') return;
37
+
38
+ instrumentation._endSpan = function endSpanWithCache(args) {
39
+ try {
40
+ const span = args && args.span;
41
+ const usage = args && args.result && args.result.usage;
42
+ const recording = span && span.isRecording && span.isRecording();
43
+ const cachedTokens =
44
+ usage && usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens;
45
+ if (cachedTokens && recording) {
46
+ span.setAttribute(CACHE_READ_INPUT_TOKENS_ATTR, cachedTokens);
47
+ }
48
+ const reasoningTokens =
49
+ usage &&
50
+ usage.completion_tokens_details &&
51
+ usage.completion_tokens_details.reasoning_tokens;
52
+ if (reasoningTokens && recording) {
53
+ span.setAttribute(REASONING_TOKENS_ATTR, reasoningTokens);
54
+ }
55
+ } catch {
56
+ // Never break span finalization over an enrichment failure.
57
+ }
58
+ return original.call(this, args);
59
+ };
60
+ this._patched.push([instrumentation, '_endSpan', original]);
61
+ }
62
+
63
+ uninstrument() {
64
+ for (const [owner, methodName, original] of this._patched) {
65
+ owner[methodName] = original;
66
+ }
67
+ this._patched = [];
68
+ }
69
+ }
70
+
71
+ module.exports = { OpenAIPromptCachingInstrumentor };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hand-written OTel instrumentor for the OpenAI embeddings endpoint.
5
+ *
6
+ * `@traceloop/instrumentation-openai` (the package weflayr uses for OpenAI on
7
+ * Node) does not wrap the embeddings endpoint, so this ships inside weflayr to
8
+ * cover it. The Python SDK gets embeddings for free from OpenLLMetry's
9
+ * instrumentor, so there is no Python counterpart to this file — but the span it
10
+ * emits is shaped to normalize identically. It is registered alongside the
11
+ * traceloop instrumentor whenever `autoInstrument({ aiSdks: 'openai' })` runs.
12
+ *
13
+ * Embeddings are billed per input token, so the span records the prompt tokens
14
+ * read off the response under `gen_ai.usage.input_tokens`; there is no completion.
15
+ */
16
+
17
+ const { trace, SpanStatusCode } = require('@opentelemetry/api');
18
+
19
+ const TRACER_NAME = 'weflayr.instrumentation.openai_embeddings';
20
+ const EMBEDDINGS_OPERATION = 'embeddings';
21
+
22
+ // Return the gen_ai.system for this call, matching how the OpenAI chat
23
+ // instrumentor attributes the vendor: Azure clients (reached via the same openai
24
+ // SDK) report `azure.ai.openai`, everything else `openai`.
25
+ function _detectSystem(instance) {
26
+ const baseURL = (instance && instance._client && instance._client.baseURL) || '';
27
+ return baseURL.toLowerCase().includes('openai.azure.com') ? 'azure.ai.openai' : 'openai';
28
+ }
29
+
30
+ function _recordError(span, error) {
31
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
32
+ span.recordException(error);
33
+ }
34
+
35
+ class OpenAIEmbeddingsInstrumentor {
36
+ constructor() {
37
+ // [ownerPrototype, methodName, originalFunction] for uninstrument().
38
+ this._patched = [];
39
+ }
40
+
41
+ instrument() {
42
+ let Embeddings;
43
+ try {
44
+ ({ Embeddings } = require('openai/resources/embeddings'));
45
+ } catch (err) {
46
+ throw new Error(
47
+ 'weflayr.autoInstrument(OPENAI): the openai client is not installed. ' +
48
+ 'Install it with: npm install openai\n' +
49
+ `Underlying error: ${err.message}`
50
+ );
51
+ }
52
+
53
+ const owner = Embeddings.prototype;
54
+ const original = owner.create;
55
+
56
+ function wrapper(body, ...rest) {
57
+ const span = trace.getTracer(TRACER_NAME).startSpan(EMBEDDINGS_OPERATION);
58
+ span.setAttribute('gen_ai.system', _detectSystem(this));
59
+ span.setAttribute('gen_ai.operation.name', EMBEDDINGS_OPERATION);
60
+ if (body && body.model) {
61
+ span.setAttribute('gen_ai.request.model', body.model);
62
+ }
63
+ return Promise.resolve(original.call(this, body, ...rest)).then(
64
+ (response) => {
65
+ if (response && response.model) {
66
+ span.setAttribute('gen_ai.response.model', response.model);
67
+ }
68
+ const promptTokens = response && response.usage && response.usage.prompt_tokens;
69
+ if (promptTokens != null) {
70
+ span.setAttribute('gen_ai.usage.input_tokens', promptTokens);
71
+ }
72
+ span.end();
73
+ return response;
74
+ },
75
+ (error) => {
76
+ _recordError(span, error);
77
+ span.end();
78
+ throw error;
79
+ }
80
+ );
81
+ }
82
+
83
+ owner.create = wrapper;
84
+ this._patched.push([owner, 'create', original]);
85
+ }
86
+
87
+ uninstrument() {
88
+ for (const [owner, methodName, original] of this._patched) {
89
+ owner[methodName] = original;
90
+ }
91
+ this._patched = [];
92
+ }
93
+ }
94
+
95
+ module.exports = { OpenAIEmbeddingsInstrumentor };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Streamed token-usage enrichment complement for the OpenAI chat path.
5
+ *
6
+ * `@traceloop/instrumentation-openai` does not record token usage for streamed
7
+ * calls: its stream handler accumulates `choices[].delta` but never reads the
8
+ * final usage chunk OpenAI sends when the request sets
9
+ * `stream_options: { include_usage: true }`. Non-streaming calls work because
10
+ * OpenAI returns `usage` in the response body, which the instrumentor reads
11
+ * directly. The Python OpenLLMetry instrumentor reads the streamed usage, so
12
+ * this gap is Node-only.
13
+ *
14
+ * It wraps the instrumentor instance's `_streamingWrapPromise`: that handler
15
+ * hands us the span and ends it only after its chunk loop, so when the usage
16
+ * chunk arrives the span is still open and we set the same `gen_ai.usage.*`
17
+ * attributes the instrumentor sets for non-streaming calls.
18
+ */
19
+
20
+ const USAGE_INPUT_TOKENS_ATTR = 'gen_ai.usage.input_tokens';
21
+ const USAGE_OUTPUT_TOKENS_ATTR = 'gen_ai.usage.output_tokens';
22
+ const USAGE_TOTAL_TOKENS_ATTR = 'gen_ai.usage.total_tokens';
23
+ const CACHE_READ_INPUT_TOKENS_ATTR = 'gen_ai.usage.cache_read_input_tokens';
24
+
25
+ function _setUsageAttributes(span, usage) {
26
+ if (typeof usage.prompt_tokens === 'number') {
27
+ span.setAttribute(USAGE_INPUT_TOKENS_ATTR, usage.prompt_tokens);
28
+ }
29
+ if (typeof usage.completion_tokens === 'number') {
30
+ span.setAttribute(USAGE_OUTPUT_TOKENS_ATTR, usage.completion_tokens);
31
+ }
32
+ if (typeof usage.total_tokens === 'number') {
33
+ span.setAttribute(USAGE_TOTAL_TOKENS_ATTR, usage.total_tokens);
34
+ }
35
+ // Cached-prompt tokens ride in the same streamed usage chunk; mirror the
36
+ // non-streamed cache backfill in get_cache_and_reasoning_telemetry.js.
37
+ const cachedTokens = usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens;
38
+ if (cachedTokens) {
39
+ span.setAttribute(CACHE_READ_INPUT_TOKENS_ATTR, cachedTokens);
40
+ }
41
+ }
42
+
43
+ class OpenAIStreamingUsageInstrumentor {
44
+ constructor() {
45
+ // [instrumentation, methodName, originalFunction] for uninstrument().
46
+ this._patched = [];
47
+ }
48
+
49
+ // No-op if the instrumentor's internals have changed shape — the worst case is
50
+ // streamed spans without token counts, never a crash.
51
+ instrument(instrumentation) {
52
+ const original = instrumentation && instrumentation._streamingWrapPromise;
53
+ if (typeof original !== 'function') return;
54
+
55
+ instrumentation._streamingWrapPromise = function streamingWrapWithUsage(streamArgs) {
56
+ const span = streamArgs && streamArgs.span;
57
+ const wrappedStream = original.call(this, streamArgs);
58
+ if (!span) return wrappedStream;
59
+
60
+ return (async function* backfillUsage() {
61
+ for await (const chunk of wrappedStream) {
62
+ const usage = chunk && chunk.usage;
63
+ if (usage && span.isRecording()) {
64
+ _setUsageAttributes(span, usage);
65
+ }
66
+ yield chunk;
67
+ }
68
+ })();
69
+ };
70
+ this._patched.push([instrumentation, '_streamingWrapPromise', original]);
71
+ }
72
+
73
+ uninstrument() {
74
+ for (const [owner, methodName, original] of this._patched) {
75
+ owner[methodName] = original;
76
+ }
77
+ this._patched = [];
78
+ }
79
+ }
80
+
81
+ module.exports = { OpenAIStreamingUsageInstrumentor };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * OpenAI telemetry complement — the registered orchestrator.
5
+ *
6
+ * Wires every weflayr-supplied patch the traceloop OpenAI instrumentor misses:
7
+ * - the audio (TTS/STT) and embeddings endpoints it doesn't cover, and
8
+ * - usage it drops on the chat endpoint it does cover: streamed token usage and
9
+ * non-streamed cached-prompt tokens (these enrich the traceloop instrumentor
10
+ * instance, hence the `instrumentation` argument).
11
+ */
12
+
13
+ const { OpenAIAudioInstrumentor } = require('./get_audio_telemetry');
14
+ const { OpenAIEmbeddingsInstrumentor } = require('./get_embeddings_telemetry');
15
+ const { OpenAIStreamingUsageInstrumentor } = require('./get_streaming_usage');
16
+ const { OpenAIPromptCachingInstrumentor } = require('./get_cache_and_reasoning_telemetry');
17
+
18
+ class OpenAITelemetryInstrumentor {
19
+ constructor() {
20
+ this._subInstrumentors = [
21
+ new OpenAIAudioInstrumentor(),
22
+ new OpenAIEmbeddingsInstrumentor(),
23
+ new OpenAIStreamingUsageInstrumentor(),
24
+ new OpenAIPromptCachingInstrumentor(),
25
+ ];
26
+ }
27
+
28
+ // The endpoint complements ignore `instrumentation`; the streaming/cache ones
29
+ // patch its internal handlers.
30
+ instrument(instrumentation) {
31
+ for (const sub of this._subInstrumentors) sub.instrument(instrumentation);
32
+ }
33
+
34
+ uninstrument() {
35
+ for (const sub of this._subInstrumentors) sub.uninstrument();
36
+ }
37
+ }
38
+
39
+ module.exports = { OpenAITelemetryInstrumentor };
@@ -62,6 +62,11 @@ function _buildAttributes({ featureName, customerId, providerName, extraTags, sp
62
62
  * propagateMetadata call" contract.
63
63
  */
64
64
  function propagateMetadata(options, fn) {
65
+ const { featureName, customerId } = options ?? {};
66
+ if (featureName == null || customerId == null) {
67
+ throw new Error('propagateMetadata: `featureName` and `customerId` are required');
68
+ }
69
+
65
70
  if (typeof fn === 'function') {
66
71
  return _runWithMetadata(options, fn);
67
72
  }
@@ -27,17 +27,13 @@ const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
27
27
  'opentelemetry.instrumentation.google_generativeai',
28
28
  'opentelemetry.instrumentation.openai',
29
29
  'opentelemetry.instrumentation.openai_v2',
30
- 'opentelemetry.instrumentation.vertex_ai',
31
- 'opentelemetry.instrumentation.vertexai',
30
+ 'weflayr.instrumentation.elevenlabs',
32
31
  'strands-agents',
33
32
  'vllm',
34
33
  '@traceloop/instrumentation-openai',
35
34
  '@traceloop/instrumentation-anthropic',
36
35
  '@traceloop/instrumentation-bedrock',
37
- '@traceloop/instrumentation-azure',
38
36
  '@traceloop/instrumentation-cohere',
39
- '@traceloop/instrumentation-together',
40
- '@traceloop/instrumentation-vertexai',
41
37
  '@traceloop/instrumentation-google-generativeai',
42
38
  '@traceloop/instrumentation-langchain',
43
39
  '@traceloop/instrumentation-llamaindex',
@@ -14,7 +14,6 @@ const { _state } = require('../src/setup');
14
14
  const { isDefaultExportSpan } = require('../src/otel/span-filter');
15
15
  const { WeflayrSpanProcessor } = require('../src/otel/span-processor');
16
16
  const { buildExporter, _exportWithRetry } = require('../src/otel/exporter');
17
- const { patchOpenAIStreamingUsage } = require('../src/otel/openai-streaming-usage');
18
17
 
19
18
  const contextManager = new AsyncHooksContextManager();
20
19
  contextManager.enable();
@@ -478,12 +477,48 @@ test('propagateMetadata providerName writes attribute', async () => {
478
477
  await provider.shutdown();
479
478
  });
480
479
 
480
+ test('propagateMetadata requires featureName and customerId', () => {
481
+ assert.throws(() => weflayr.propagateMetadata({}), /featureName.*customerId.*required/);
482
+ assert.throws(() => weflayr.propagateMetadata({ featureName: 'f' }), /required/);
483
+ assert.throws(() => weflayr.propagateMetadata({ customerId: 'c' }), /required/);
484
+ });
485
+
481
486
  // ── autoInstrument ────────────────────────────────────────────────────────────
482
487
 
483
488
  function _ensureSetupDone() {
484
489
  if (!_state.processor) weflayr.setup({ apiKey: 'test-key' });
485
490
  }
486
491
 
492
+ // Instrumenting OPENAI also runs the bundled audio + embeddings instrumentors,
493
+ // which eagerly require the openai resource modules. Stub them so autoInstrument
494
+ // tests don't need the real `openai` dependency installed.
495
+ const OPENAI_RESOURCE_STUBS = {
496
+ 'openai/resources/audio/speech': {
497
+ Speech: class {
498
+ create() {}
499
+ },
500
+ },
501
+ 'openai/resources/audio/transcriptions': {
502
+ Transcriptions: class {
503
+ create() {}
504
+ },
505
+ },
506
+ 'openai/resources/embeddings': {
507
+ Embeddings: class {
508
+ create() {}
509
+ },
510
+ },
511
+ };
512
+
513
+ function _stubOpenAIResources() {
514
+ for (const [id, exports] of Object.entries(OPENAI_RESOURCE_STUBS)) {
515
+ require.cache[id] = { id, filename: id, loaded: true, exports };
516
+ }
517
+ return () => {
518
+ for (const id of Object.keys(OPENAI_RESOURCE_STUBS)) delete require.cache[id];
519
+ };
520
+ }
521
+
487
522
  test('autoInstrument throws "install package X" when the dep is missing', async () => {
488
523
  _ensureSetupDone();
489
524
  await assert.rejects(
@@ -517,7 +552,7 @@ test('autoInstrument loads a stubbed Traceloop package', async () => {
517
552
  }
518
553
  const origResolve = Module._resolveFilename;
519
554
  Module._resolveFilename = function patched(req, ...rest) {
520
- if (req === '@traceloop/instrumentation-openai') return req;
555
+ if (req === '@traceloop/instrumentation-openai' || req in OPENAI_RESOURCE_STUBS) return req;
521
556
  return origResolve.call(this, req, ...rest);
522
557
  };
523
558
  require.cache['@traceloop/instrumentation-openai'] = {
@@ -526,12 +561,14 @@ test('autoInstrument loads a stubbed Traceloop package', async () => {
526
561
  loaded: true,
527
562
  exports: { OpenAIInstrumentation: FakeOpenAIInstrumentation },
528
563
  };
564
+ const cleanupOpenAI = _stubOpenAIResources();
529
565
  try {
530
566
  await weflayr.autoInstrument({ aiSdks: weflayr.AiSdk.OPENAI });
531
567
  assert.equal(constructed, true);
532
568
  } finally {
533
569
  Module._resolveFilename = origResolve;
534
570
  delete require.cache['@traceloop/instrumentation-openai'];
571
+ cleanupOpenAI();
535
572
  }
536
573
  });
537
574
 
@@ -564,7 +601,7 @@ test('autoInstrument runs setup when apiKey passed', async () => {
564
601
  }
565
602
  const origResolve = Module._resolveFilename;
566
603
  Module._resolveFilename = function patched(req, ...rest) {
567
- if (req === '@traceloop/instrumentation-openai') return req;
604
+ if (req === '@traceloop/instrumentation-openai' || req in OPENAI_RESOURCE_STUBS) return req;
568
605
  return origResolve.call(this, req, ...rest);
569
606
  };
570
607
  require.cache['@traceloop/instrumentation-openai'] = {
@@ -573,6 +610,7 @@ test('autoInstrument runs setup when apiKey passed', async () => {
573
610
  loaded: true,
574
611
  exports: { OpenAIInstrumentation: FakeOpenAIInstrumentation },
575
612
  };
613
+ const cleanupOpenAI = _stubOpenAIResources();
576
614
  try {
577
615
  await weflayr.autoInstrument({
578
616
  aiSdks: weflayr.AiSdk.OPENAI,
@@ -582,6 +620,7 @@ test('autoInstrument runs setup when apiKey passed', async () => {
582
620
  } finally {
583
621
  Module._resolveFilename = origResolve;
584
622
  delete require.cache['@traceloop/instrumentation-openai'];
623
+ cleanupOpenAI();
585
624
  }
586
625
  });
587
626
 
@@ -641,47 +680,6 @@ test('exporter gives up after maxRetries on persistent 503', async () => {
641
680
  assert.equal(calls, 3);
642
681
  });
643
682
 
644
- // ── OpenAI streaming usage backfill ─────────────────────────────────────────────
645
-
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
- }
665
-
666
- test('patchOpenAIStreamingUsage sets token attributes from the streamed usage chunk', async () => {
667
- const span = makeFakeSpan();
668
- const instrumentation = makeFakeOpenAIInstrumentation();
669
- patchOpenAIStreamingUsage(instrumentation);
670
-
671
- const stream = instrumentation._streamingWrapPromise({ span });
672
- for await (const chunk of stream) void chunk;
673
-
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);
677
- });
678
-
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
- });
684
-
685
683
  // ── flush ─────────────────────────────────────────────────────────────────────
686
684
 
687
685
  test('flush does not throw', async () => {
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // Tests for the Anthropic cached-prompt token complement. It wraps the primary
4
+ // instrumentor's `_endSpan({ span, type, result })` to stamp
5
+ // gen_ai.usage.cache_read_input_tokens / cache_creation_input_tokens from the
6
+ // response's `usage.cache_read_input_tokens` / `cache_creation_input_tokens`.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+
11
+ const {
12
+ AnthropicPromptCachingInstrumentor,
13
+ } = require('../../../src/instrumentation/anthropic/get_cache_and_reasoning_telemetry');
14
+
15
+ function fakeSpan() {
16
+ const attrs = {};
17
+ return {
18
+ attrs,
19
+ isRecording: () => true,
20
+ setAttribute(key, value) {
21
+ attrs[key] = value;
22
+ },
23
+ };
24
+ }
25
+
26
+ test('stamps cache write and still calls the original _endSpan', () => {
27
+ const calls = [];
28
+ const instrumentation = {
29
+ _endSpan(args) {
30
+ calls.push(args);
31
+ },
32
+ };
33
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
34
+
35
+ const span = fakeSpan();
36
+ instrumentation._endSpan({
37
+ span,
38
+ type: 'completion',
39
+ result: {
40
+ usage: { input_tokens: 10, cache_creation_input_tokens: 5269, cache_read_input_tokens: 0 },
41
+ },
42
+ });
43
+
44
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], 5269);
45
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
46
+ assert.equal(calls.length, 1);
47
+ });
48
+
49
+ test('stamps cache read on a warm call', () => {
50
+ const instrumentation = { _endSpan() {} };
51
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
52
+
53
+ const span = fakeSpan();
54
+ instrumentation._endSpan({
55
+ span,
56
+ type: 'completion',
57
+ result: {
58
+ usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 5271 },
59
+ },
60
+ });
61
+
62
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], 5271);
63
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], undefined);
64
+ });
65
+
66
+ test('sets nothing for an uncached call', () => {
67
+ const instrumentation = { _endSpan() {} };
68
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
69
+
70
+ const span = fakeSpan();
71
+ instrumentation._endSpan({ span, type: 'completion', result: { usage: { input_tokens: 13 } } });
72
+
73
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
74
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], undefined);
75
+ });
76
+
77
+ test('stamps extended thinking tokens from output_tokens_details', () => {
78
+ const instrumentation = { _endSpan() {} };
79
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
80
+
81
+ const span = fakeSpan();
82
+ instrumentation._endSpan({
83
+ span,
84
+ type: 'completion',
85
+ result: { usage: { input_tokens: 10, output_tokens_details: { thinking_tokens: 150 } } },
86
+ });
87
+
88
+ assert.equal(span.attrs['gen_ai.usage.output_tokens_details.thinking_tokens'], 150);
89
+ });
90
+
91
+ test('stamps cache + raw input from the streamed message_start usage', async () => {
92
+ const instrumentation = {
93
+ _endSpan() {},
94
+ _streamingWrapPromise() {
95
+ return (async function* () {
96
+ yield {
97
+ type: 'message_start',
98
+ message: {
99
+ usage: {
100
+ input_tokens: 10,
101
+ cache_read_input_tokens: 5270,
102
+ cache_creation_input_tokens: 0,
103
+ },
104
+ },
105
+ };
106
+ yield { type: 'content_block_delta', delta: { text: 'OK' } };
107
+ })();
108
+ },
109
+ };
110
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
111
+
112
+ const span = fakeSpan();
113
+ for await (const _chunk of instrumentation._streamingWrapPromise('client', 'mod', { span })) {
114
+ void _chunk;
115
+ }
116
+
117
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], 5270);
118
+ assert.equal(span.attrs['gen_ai.usage.input_tokens'], 10);
119
+ });
120
+
121
+ test('is a no-op when the instrumentor has no _endSpan', () => {
122
+ const instrumentation = {};
123
+ new AnthropicPromptCachingInstrumentor().instrument(instrumentation);
124
+ assert.equal(instrumentation._endSpan, undefined);
125
+ });