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.
- package/package.json +2 -2
- package/src/auto-instrument.js +64 -31
- package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +107 -0
- package/src/instrumentation/anthropic/set_telemetry_attributes.js +27 -0
- package/src/instrumentation/bedrock/get_cache_telemetry.js +104 -0
- package/src/instrumentation/bedrock/set_telemetry_attributes.js +27 -0
- package/src/instrumentation/cohere/get_chat_telemetry.js +88 -0
- package/src/instrumentation/cohere/get_embeddings_telemetry.js +97 -0
- package/src/instrumentation/cohere/set_telemetry_attributes.js +32 -0
- package/src/instrumentation/google/get_audio_telemetry.js +31 -0
- package/src/instrumentation/google/get_cache_telemetry.js +27 -0
- package/src/instrumentation/google/get_embeddings_telemetry.js +78 -0
- package/src/instrumentation/google/get_provider_name.js +25 -0
- package/src/instrumentation/google/get_reasoning_telemetry.js +21 -0
- package/src/instrumentation/google/set_telemetry_attributes.js +98 -0
- package/src/instrumentation/openai/get_audio_telemetry.js +149 -0
- package/src/instrumentation/openai/get_cache_and_reasoning_telemetry.js +71 -0
- package/src/instrumentation/openai/get_embeddings_telemetry.js +95 -0
- package/src/instrumentation/openai/get_streaming_usage.js +81 -0
- package/src/instrumentation/openai/set_telemetry_attributes.js +39 -0
- package/src/otel/propagation.js +5 -0
- package/src/otel/span-filter.js +0 -5
- package/tests/index.test.js +42 -44
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +125 -0
- package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +102 -0
- package/tests/instrumentation/cohere/get_chat_telemetry.test.js +148 -0
- package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +129 -0
- package/tests/instrumentation/{elevenlabs.test.js → elevenlabs/full_instrumentation.test.js} +3 -1
- package/tests/instrumentation/google/get_embeddings_telemetry.test.js +125 -0
- package/tests/instrumentation/google/get_reasoning_telemetry.test.js +41 -0
- package/tests/instrumentation/google/set_telemetry_attributes.test.js +187 -0
- package/tests/instrumentation/openai/get_audio_telemetry.test.js +170 -0
- package/tests/instrumentation/openai/get_cache_and_reasoning_telemetry.test.js +83 -0
- package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +126 -0
- package/tests/instrumentation/openai/get_streaming_usage.test.js +76 -0
- package/src/otel/openai-streaming-usage.js +0 -60
- /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 };
|
package/src/otel/propagation.js
CHANGED
|
@@ -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
|
}
|
package/src/otel/span-filter.js
CHANGED
|
@@ -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',
|
package/tests/index.test.js
CHANGED
|
@@ -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
|
+
});
|