weflayr 0.20.6 → 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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/auto-instrument.js +64 -31
  3. package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +107 -0
  4. package/src/instrumentation/anthropic/set_telemetry_attributes.js +27 -0
  5. package/src/instrumentation/bedrock/get_cache_telemetry.js +104 -0
  6. package/src/instrumentation/bedrock/set_telemetry_attributes.js +27 -0
  7. package/src/instrumentation/cohere/get_chat_telemetry.js +88 -0
  8. package/src/instrumentation/cohere/get_embeddings_telemetry.js +97 -0
  9. package/src/instrumentation/cohere/set_telemetry_attributes.js +32 -0
  10. package/src/instrumentation/google/get_audio_telemetry.js +31 -0
  11. package/src/instrumentation/google/get_cache_telemetry.js +27 -0
  12. package/src/instrumentation/google/get_embeddings_telemetry.js +78 -0
  13. package/src/instrumentation/google/get_provider_name.js +25 -0
  14. package/src/instrumentation/google/get_reasoning_telemetry.js +21 -0
  15. package/src/instrumentation/google/set_telemetry_attributes.js +98 -0
  16. package/src/instrumentation/openai/get_audio_telemetry.js +149 -0
  17. package/src/instrumentation/openai/get_cache_and_reasoning_telemetry.js +71 -0
  18. package/src/instrumentation/openai/get_embeddings_telemetry.js +95 -0
  19. package/src/instrumentation/openai/get_streaming_usage.js +81 -0
  20. package/src/instrumentation/openai/set_telemetry_attributes.js +39 -0
  21. package/src/otel/propagation.js +5 -0
  22. package/src/otel/span-filter.js +0 -5
  23. package/tests/index.test.js +42 -44
  24. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +125 -0
  25. package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +102 -0
  26. package/tests/instrumentation/cohere/get_chat_telemetry.test.js +148 -0
  27. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +129 -0
  28. package/tests/instrumentation/{elevenlabs.test.js → elevenlabs/full_instrumentation.test.js} +3 -1
  29. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +125 -0
  30. package/tests/instrumentation/google/get_reasoning_telemetry.test.js +41 -0
  31. package/tests/instrumentation/google/set_telemetry_attributes.test.js +187 -0
  32. package/tests/instrumentation/openai/get_audio_telemetry.test.js +170 -0
  33. package/tests/instrumentation/openai/get_cache_and_reasoning_telemetry.test.js +83 -0
  34. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +126 -0
  35. package/tests/instrumentation/openai/get_streaming_usage.test.js +76 -0
  36. package/src/otel/openai-streaming-usage.js +0 -60
  37. /package/src/instrumentation/{elevenlabs.js → elevenlabs/full_instrumentation.js} +0 -0
@@ -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,18 +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',
32
30
  'weflayr.instrumentation.elevenlabs',
33
31
  'strands-agents',
34
32
  'vllm',
35
33
  '@traceloop/instrumentation-openai',
36
34
  '@traceloop/instrumentation-anthropic',
37
35
  '@traceloop/instrumentation-bedrock',
38
- '@traceloop/instrumentation-azure',
39
36
  '@traceloop/instrumentation-cohere',
40
- '@traceloop/instrumentation-together',
41
- '@traceloop/instrumentation-vertexai',
42
37
  '@traceloop/instrumentation-google-generativeai',
43
38
  '@traceloop/instrumentation-langchain',
44
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
+ });
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // Tests for the Bedrock cached-prompt token complement. It wraps the primary
4
+ // instrumentor's `_wrapPromise(span, promise, modelId)` to read Nova's cache-token
5
+ // counts off the decoded InvokeModel response body and stamp them as
6
+ // gen_ai.usage.cache_* attributes.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+
11
+ const {
12
+ BedrockPromptCachingInstrumentor,
13
+ } = require('../../../src/instrumentation/bedrock/get_cache_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
+ function encode(obj) {
27
+ return new TextEncoder().encode(JSON.stringify(obj));
28
+ }
29
+
30
+ // Pass-through original: resolve to whatever promise it was handed.
31
+ function passthroughInstrumentation() {
32
+ return { _wrapPromise: (_span, promise) => Promise.resolve(promise) };
33
+ }
34
+
35
+ test('stamps cache read/write from a buffered InvokeModel body', async () => {
36
+ const instrumentation = passthroughInstrumentation();
37
+ new BedrockPromptCachingInstrumentor().instrument(instrumentation);
38
+
39
+ const span = fakeSpan();
40
+ const body = encode({
41
+ usage: { cacheReadInputTokenCount: 100, cacheWriteInputTokenCount: 1300 },
42
+ });
43
+ await instrumentation._wrapPromise(span, Promise.resolve({ body }), 'amazon.nova-micro-v1:0');
44
+
45
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], 100);
46
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], 1300);
47
+ });
48
+
49
+ test('sets nothing when cache counts are zero', async () => {
50
+ const instrumentation = passthroughInstrumentation();
51
+ new BedrockPromptCachingInstrumentor().instrument(instrumentation);
52
+
53
+ const span = fakeSpan();
54
+ const body = encode({ usage: { cacheReadInputTokenCount: 0, cacheWriteInputTokenCount: 0 } });
55
+ await instrumentation._wrapPromise(span, Promise.resolve({ body }), 'm');
56
+
57
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
58
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], undefined);
59
+ });
60
+
61
+ test('skips a streamed (async-iterable) body without consuming it', async () => {
62
+ const instrumentation = passthroughInstrumentation();
63
+ new BedrockPromptCachingInstrumentor().instrument(instrumentation);
64
+
65
+ const span = fakeSpan();
66
+ let consumed = false;
67
+ const body = (async function* () {
68
+ consumed = true;
69
+ yield {};
70
+ })();
71
+ await instrumentation._wrapPromise(span, Promise.resolve({ body }), 'm');
72
+
73
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
74
+ assert.equal(consumed, false);
75
+ });
76
+
77
+ test('stamps cache from the streamed metadata chunk and calls the original', () => {
78
+ const calls = [];
79
+ const instrumentation = {
80
+ _handleNovaStreamingMetadata(span, parsed) {
81
+ calls.push(parsed);
82
+ },
83
+ };
84
+ new BedrockPromptCachingInstrumentor().instrument(instrumentation);
85
+
86
+ const span = fakeSpan();
87
+ instrumentation._handleNovaStreamingMetadata(span, {
88
+ metadata: {
89
+ usage: { inputTokens: 3, cacheReadInputTokenCount: 1458, cacheWriteInputTokenCount: 0 },
90
+ },
91
+ });
92
+
93
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], 1458);
94
+ assert.equal(span.attrs['gen_ai.usage.cache_creation_input_tokens'], undefined);
95
+ assert.equal(calls.length, 1);
96
+ });
97
+
98
+ test('is a no-op when the instrumentor has no _wrapPromise', () => {
99
+ const instrumentation = {};
100
+ new BedrockPromptCachingInstrumentor().instrument(instrumentation);
101
+ assert.equal(instrumentation._wrapPromise, undefined);
102
+ });
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // Tests for the Cohere chat token-usage enrichment complement.
4
+ //
5
+ // The cohere-ai v1 `CohereClient` is stubbed with an internal `__chat` method
6
+ // that mirrors the SDK shape — resolving to `{ data: { meta: { billedUnits } } }`
7
+ // — so the complement can be exercised without network access or the dependency
8
+ // installed. The complement enriches whichever span is active when `__chat` runs
9
+ // (in production, the span traceloop opens around the public `chat`), so the
10
+ // tests start a span and make it active before invoking `__chat`.
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const Module = require('node:module');
15
+
16
+ const { trace, context } = require('@opentelemetry/api');
17
+ const {
18
+ BasicTracerProvider,
19
+ InMemorySpanExporter,
20
+ SimpleSpanProcessor,
21
+ } = require('@opentelemetry/sdk-trace-base');
22
+ const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks');
23
+
24
+ const {
25
+ CohereChatTokenUsageInstrumentor,
26
+ } = require('../../../src/instrumentation/cohere/get_chat_telemetry');
27
+
28
+ const CLIENT_MODULE = 'cohere-ai/Client';
29
+
30
+ const exporter = new InMemorySpanExporter();
31
+ trace.setGlobalTracerProvider(
32
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
33
+ );
34
+ context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
35
+ const tracer = trace.getTracer('test');
36
+
37
+ class StubCohereClient {
38
+ __chat(request = {}) {
39
+ if (request.model === 'boom') return Promise.reject(new Error('api error'));
40
+ return Promise.resolve({
41
+ data: { text: 'hi', meta: { billedUnits: { inputTokens: 7, outputTokens: 3 } } },
42
+ });
43
+ }
44
+ }
45
+
46
+ function instrumented() {
47
+ const stubs = { [CLIENT_MODULE]: { CohereClient: StubCohereClient } };
48
+ const origResolve = Module._resolveFilename;
49
+ Module._resolveFilename = function patched(request, ...rest) {
50
+ if (request in stubs) return request;
51
+ return origResolve.call(this, request, ...rest);
52
+ };
53
+ for (const [moduleId, exports] of Object.entries(stubs)) {
54
+ require.cache[moduleId] = { id: moduleId, filename: moduleId, loaded: true, exports };
55
+ }
56
+
57
+ const instrumentor = new CohereChatTokenUsageInstrumentor();
58
+ instrumentor.instrument();
59
+
60
+ return () => {
61
+ instrumentor.uninstrument();
62
+ Module._resolveFilename = origResolve;
63
+ for (const moduleId of Object.keys(stubs)) delete require.cache[moduleId];
64
+ exporter.reset();
65
+ };
66
+ }
67
+
68
+ // Runs `__chat` inside an active span, mirroring the context traceloop sets up
69
+ // around the public `chat`, and returns the finished span.
70
+ async function callChatWithinSpan(client, request) {
71
+ const span = tracer.startSpan('cohere.chat');
72
+ await context.with(trace.setSpan(context.active(), span), () => client.__chat(request));
73
+ span.end();
74
+ return exporter.getFinishedSpans().find((finished) => finished.name === 'cohere.chat');
75
+ }
76
+
77
+ test('enriches the active chat span with input and output tokens', async () => {
78
+ const teardown = instrumented();
79
+ try {
80
+ const client = new StubCohereClient();
81
+ const span = await callChatWithinSpan(client, { model: 'command-r-08-2024', message: 'hi' });
82
+
83
+ assert.equal(span.attributes['gen_ai.usage.input_tokens'], 7);
84
+ assert.equal(span.attributes['gen_ai.usage.output_tokens'], 3);
85
+ } finally {
86
+ teardown();
87
+ }
88
+ });
89
+
90
+ test('leaves the response unchanged', async () => {
91
+ const teardown = instrumented();
92
+ try {
93
+ const client = new StubCohereClient();
94
+ let result;
95
+ const span = tracer.startSpan('cohere.chat');
96
+ await context.with(trace.setSpan(context.active(), span), async () => {
97
+ result = await client.__chat({ model: 'command-r-08-2024', message: 'hi' });
98
+ });
99
+ span.end();
100
+
101
+ assert.equal(result.data.text, 'hi');
102
+ assert.equal(result.data.meta.billedUnits.inputTokens, 7);
103
+ } finally {
104
+ teardown();
105
+ }
106
+ });
107
+
108
+ test('does nothing when no span is active', async () => {
109
+ const teardown = instrumented();
110
+ try {
111
+ const client = new StubCohereClient();
112
+ // No active span — must not throw, just pass the response through.
113
+ const result = await client.__chat({ model: 'command-r-08-2024', message: 'hi' });
114
+ assert.equal(result.data.meta.billedUnits.outputTokens, 3);
115
+ assert.equal(exporter.getFinishedSpans().length, 0);
116
+ } finally {
117
+ teardown();
118
+ }
119
+ });
120
+
121
+ test('propagates errors without enriching the span', async () => {
122
+ const teardown = instrumented();
123
+ try {
124
+ const client = new StubCohereClient();
125
+ const span = tracer.startSpan('cohere.chat');
126
+ await assert.rejects(
127
+ () =>
128
+ context.with(trace.setSpan(context.active(), span), () =>
129
+ client.__chat({ model: 'boom', message: 'x' })
130
+ ),
131
+ /api error/
132
+ );
133
+ span.end();
134
+
135
+ const finished = exporter.getFinishedSpans().find((each) => each.name === 'cohere.chat');
136
+ assert.equal(finished.attributes['gen_ai.usage.input_tokens'], undefined);
137
+ } finally {
138
+ teardown();
139
+ }
140
+ });
141
+
142
+ test('uninstrument restores the original method', async () => {
143
+ const teardown = instrumented();
144
+ const { CohereClient } = require(CLIENT_MODULE);
145
+ const wrapped = CohereClient.prototype.__chat;
146
+ teardown();
147
+ assert.notEqual(CohereClient.prototype.__chat, wrapped);
148
+ });