weflayr 0.20.6 → 0.22.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 (49) hide show
  1. package/index.d.ts +1 -1
  2. package/package.json +2 -2
  3. package/src/auto-instrument.js +64 -31
  4. package/src/instrumentation/anthropic/get_cache_and_reasoning_telemetry.js +107 -0
  5. package/src/instrumentation/anthropic/get_streaming_stop_reason_telemetry.js +121 -0
  6. package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
  7. package/src/instrumentation/anthropic/set_telemetry_attributes.js +37 -0
  8. package/src/instrumentation/bedrock/get_cache_telemetry.js +104 -0
  9. package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
  10. package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
  11. package/src/instrumentation/bedrock/set_telemetry_attributes.js +36 -0
  12. package/src/instrumentation/cohere/get_chat_telemetry.js +88 -0
  13. package/src/instrumentation/cohere/get_embeddings_telemetry.js +107 -0
  14. package/src/instrumentation/cohere/set_telemetry_attributes.js +32 -0
  15. package/src/instrumentation/google/get_audio_telemetry.js +30 -0
  16. package/src/instrumentation/google/get_cache_telemetry.js +25 -0
  17. package/src/instrumentation/google/get_embeddings_telemetry.js +99 -0
  18. package/src/instrumentation/google/get_provider_name.js +25 -0
  19. package/src/instrumentation/google/get_reasoning_telemetry.js +21 -0
  20. package/src/instrumentation/google/set_telemetry_attributes.js +98 -0
  21. package/src/instrumentation/openai/get_audio_telemetry.js +149 -0
  22. package/src/instrumentation/openai/get_cache_and_reasoning_telemetry.js +71 -0
  23. package/src/instrumentation/openai/get_embeddings_telemetry.js +107 -0
  24. package/src/instrumentation/openai/get_streaming_usage.js +81 -0
  25. package/src/instrumentation/openai/set_telemetry_attributes.js +39 -0
  26. package/src/otel/exporter.js +1 -6
  27. package/src/otel/propagation.js +5 -0
  28. package/src/otel/span-filter.js +0 -5
  29. package/src/otel/span-processor.js +30 -1
  30. package/src/setup.js +1 -2
  31. package/tests/index.test.js +144 -44
  32. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +158 -0
  33. package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
  34. package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
  35. package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +102 -0
  36. package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
  37. package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
  38. package/tests/instrumentation/cohere/get_chat_telemetry.test.js +148 -0
  39. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +132 -0
  40. package/tests/instrumentation/{elevenlabs.test.js → elevenlabs/full_instrumentation.test.js} +21 -5
  41. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +128 -0
  42. package/tests/instrumentation/google/get_reasoning_telemetry.test.js +41 -0
  43. package/tests/instrumentation/google/set_telemetry_attributes.test.js +219 -0
  44. package/tests/instrumentation/openai/get_audio_telemetry.test.js +170 -0
  45. package/tests/instrumentation/openai/get_cache_and_reasoning_telemetry.test.js +83 -0
  46. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +145 -0
  47. package/tests/instrumentation/openai/get_streaming_usage.test.js +105 -0
  48. package/src/otel/openai-streaming-usage.js +0 -60
  49. /package/src/instrumentation/{elevenlabs.js → elevenlabs/full_instrumentation.js} +0 -0
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ // Tests for the bundled Google GenAI (Gemini) embeddings telemetry.
4
+ //
5
+ // The @google/genai `Models` class is stubbed with the prototype method
6
+ // `embedContentInternal` (the stable patch point that the public `embedContent`
7
+ // arrow delegates to). The Gemini Developer API returns no token usage, so the
8
+ // billable unit is the input character count. `instrumentEmbeddings(Models,
9
+ // patched)` wraps the method and pushes its restore tuple onto `patched`.
10
+
11
+ const test = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+
14
+ const { trace, SpanStatusCode } = require('@opentelemetry/api');
15
+ const {
16
+ BasicTracerProvider,
17
+ InMemorySpanExporter,
18
+ SimpleSpanProcessor,
19
+ } = require('@opentelemetry/sdk-trace-base');
20
+
21
+ const {
22
+ instrumentEmbeddings,
23
+ } = require('../../../src/instrumentation/google/get_embeddings_telemetry');
24
+
25
+ const exporter = new InMemorySpanExporter();
26
+ trace.setGlobalTracerProvider(
27
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
28
+ );
29
+
30
+ class StubModels {
31
+ embedContentInternal(params = {}) {
32
+ if (params.model === 'boom') return Promise.reject(new Error('api error'));
33
+ return Promise.resolve({ embeddings: [{ values: [0.1] }] });
34
+ }
35
+ }
36
+
37
+ function instrumented() {
38
+ const patched = [];
39
+ instrumentEmbeddings(StubModels, patched);
40
+ return () => {
41
+ for (const [owner, methodName, original] of patched) owner[methodName] = original;
42
+ exporter.reset();
43
+ };
44
+ }
45
+
46
+ test('embeddings emits genai span with character count and stripped model', async () => {
47
+ const teardown = instrumented();
48
+ try {
49
+ const client = new StubModels();
50
+ await client.embedContentInternal({
51
+ model: 'models/text-embedding-004',
52
+ contents: 'hello world',
53
+ });
54
+
55
+ const [span] = exporter.getFinishedSpans();
56
+ assert.equal(span.name, 'embeddings');
57
+ // No apiClient on the stub → Gemini Developer API.
58
+ assert.equal(span.attributes['gen_ai.system'], 'gemini');
59
+ assert.equal(span.attributes['gen_ai.operation.name'], 'embeddings');
60
+ // The "models/" prefix is stripped to match the chat path.
61
+ assert.equal(span.attributes['gen_ai.request.model'], 'text-embedding-004');
62
+ assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello world'.length);
63
+ assert.deepEqual(JSON.parse(span.attributes['gen_ai.input.messages']), [
64
+ { role: 'user', parts: [{ type: 'text', content: 'hello world' }] },
65
+ ]);
66
+ assert.equal(span.status.code, SpanStatusCode.UNSET);
67
+ } finally {
68
+ teardown();
69
+ }
70
+ });
71
+
72
+ test('embeddings counts characters across list/part contents', async () => {
73
+ const teardown = instrumented();
74
+ try {
75
+ const client = new StubModels();
76
+ await client.embedContentInternal({
77
+ model: 'text-embedding-004',
78
+ contents: ['ab', { text: 'cde' }, { parts: [{ text: 'fg' }] }],
79
+ });
80
+
81
+ const [span] = exporter.getFinishedSpans();
82
+ assert.equal(span.attributes['gen_ai.usage.input_characters'], 7);
83
+ } finally {
84
+ teardown();
85
+ }
86
+ });
87
+
88
+ test('embeddings system is vertex_ai when client targets vertex', async () => {
89
+ const teardown = instrumented();
90
+ try {
91
+ const client = new StubModels();
92
+ // The Vertex backend is read off the api client.
93
+ client.apiClient = { isVertexAI: () => true };
94
+ await client.embedContentInternal({ model: 'text-embedding-004', contents: 'hi' });
95
+
96
+ const [span] = exporter.getFinishedSpans();
97
+ assert.equal(span.attributes['gen_ai.system'], 'vertex_ai');
98
+ } finally {
99
+ teardown();
100
+ }
101
+ });
102
+
103
+ test('embeddings records error status on failure', async () => {
104
+ const teardown = instrumented();
105
+ try {
106
+ const client = new StubModels();
107
+ await assert.rejects(
108
+ () => client.embedContentInternal({ model: 'boom', contents: 'x' }),
109
+ /api error/
110
+ );
111
+
112
+ const [span] = exporter.getFinishedSpans();
113
+ assert.equal(span.status.code, SpanStatusCode.ERROR);
114
+ } finally {
115
+ teardown();
116
+ }
117
+ });
118
+
119
+ test('uninstrument restores the original method', async () => {
120
+ const teardown = instrumented();
121
+ const client = new StubModels();
122
+ await client.embedContentInternal({ model: 'text-embedding-004', contents: 'x' });
123
+ assert.equal(exporter.getFinishedSpans().length, 1);
124
+ teardown();
125
+ // After teardown the prototype method is restored (no new span is recorded).
126
+ await client.embedContentInternal({ model: 'text-embedding-004', contents: 'y' });
127
+ assert.equal(exporter.getFinishedSpans().length, 0);
128
+ });
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Tests for the Gemini reasoning (thinking) token complement. It stamps
4
+ // usageMetadata.thoughtsTokenCount onto the chat span as
5
+ // gen_ai.usage.thoughts_token_count so the normalizer can fold the
6
+ // (separately-reported) thinking tokens into the completion count.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+
11
+ const {
12
+ setReasoningTelemetry,
13
+ } = require('../../../src/instrumentation/google/get_reasoning_telemetry');
14
+
15
+ function fakeSpan() {
16
+ const attrs = {};
17
+ return {
18
+ attrs,
19
+ setAttribute(key, value) {
20
+ attrs[key] = value;
21
+ },
22
+ };
23
+ }
24
+
25
+ test('stamps thoughts token count', () => {
26
+ const span = fakeSpan();
27
+ setReasoningTelemetry(span, { usageMetadata: { thoughtsTokenCount: 369 } });
28
+ assert.equal(span.attrs['gen_ai.usage.thoughts_token_count'], 369);
29
+ });
30
+
31
+ test('sets nothing when thoughts is zero', () => {
32
+ const span = fakeSpan();
33
+ setReasoningTelemetry(span, { usageMetadata: { thoughtsTokenCount: 0 } });
34
+ assert.equal(span.attrs['gen_ai.usage.thoughts_token_count'], undefined);
35
+ });
36
+
37
+ test('is a no-op without usageMetadata', () => {
38
+ const span = fakeSpan();
39
+ setReasoningTelemetry(span, {});
40
+ assert.equal(Object.keys(span.attrs).length, 0);
41
+ });
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ // Tests for the Gemini generate_content enrichment complement.
4
+ //
5
+ // It stamps `gen_ai.system` (vertex_ai/gemini) and audio token details onto the
6
+ // ACTIVE span (traceloop's chat span) when `generateContent{,Stream}Internal`
7
+ // runs. The @google/genai `Models` class is stubbed; the test plays traceloop by
8
+ // starting a span and making it active around the call.
9
+
10
+ const test = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const Module = require('node:module');
13
+
14
+ const { trace, context } = require('@opentelemetry/api');
15
+ const {
16
+ BasicTracerProvider,
17
+ InMemorySpanExporter,
18
+ SimpleSpanProcessor,
19
+ } = require('@opentelemetry/sdk-trace-base');
20
+ const { AsyncLocalStorageContextManager } = require('@opentelemetry/context-async-hooks');
21
+
22
+ const {
23
+ GoogleTelemetryInstrumentor,
24
+ } = require('../../../src/instrumentation/google/set_telemetry_attributes');
25
+
26
+ const GENAI_MODULE = '@google/genai';
27
+
28
+ // The exact chunk objects the streaming stub yields. Shared so the passthrough
29
+ // test can assert object identity — proof our wrapper re-yields the user's chunks
30
+ // without copying or mutating them. The last chunk carries the streamed usage.
31
+ const STREAM_CHUNKS = [
32
+ { text: 'hello ' },
33
+ { text: 'world', usageMetadata: { cachedContentTokenCount: 32 } },
34
+ ];
35
+
36
+ const exporter = new InMemorySpanExporter();
37
+ trace.setGlobalTracerProvider(
38
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
39
+ );
40
+ context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
41
+
42
+ class StubModels {
43
+ generateContentInternal(params = {}) {
44
+ return Promise.resolve({
45
+ text: 'ok',
46
+ usageMetadata: {
47
+ promptTokensDetails: params.audioInput
48
+ ? [{ modality: 'AUDIO', tokenCount: 9 }]
49
+ : [{ modality: 'TEXT', tokenCount: 2 }],
50
+ candidatesTokensDetails: params.audioOutput
51
+ ? [{ modality: 'AUDIO', tokenCount: 17 }]
52
+ : [{ modality: 'TEXT', tokenCount: 5 }],
53
+ cachedContentTokenCount: params.cached || 0,
54
+ cacheTokensDetails: params.cacheDetails,
55
+ },
56
+ });
57
+ }
58
+
59
+ generateContentStreamInternal() {
60
+ return Promise.resolve(
61
+ (async function* () {
62
+ for (const chunk of STREAM_CHUNKS) yield chunk;
63
+ })()
64
+ );
65
+ }
66
+ }
67
+
68
+ function instrumented() {
69
+ const stubs = { [GENAI_MODULE]: { Models: StubModels } };
70
+ const origResolve = Module._resolveFilename;
71
+ Module._resolveFilename = function patched(request, ...rest) {
72
+ if (request in stubs) return request;
73
+ return origResolve.call(this, request, ...rest);
74
+ };
75
+ for (const [moduleId, exports] of Object.entries(stubs)) {
76
+ require.cache[moduleId] = { id: moduleId, filename: moduleId, loaded: true, exports };
77
+ }
78
+ const instrumentor = new GoogleTelemetryInstrumentor();
79
+ instrumentor.instrument();
80
+ return () => {
81
+ instrumentor.uninstrument();
82
+ Module._resolveFilename = origResolve;
83
+ for (const moduleId of Object.keys(stubs)) delete require.cache[moduleId];
84
+ exporter.reset();
85
+ };
86
+ }
87
+
88
+ async function withActiveSpan(fn) {
89
+ const span = trace.getTracer('test').startSpan('generate_content');
90
+ await context.with(trace.setSpan(context.active(), span), fn);
91
+ span.end();
92
+ return exporter.getFinishedSpans().at(-1);
93
+ }
94
+
95
+ test('stamps provider gemini and audio output tokens on the active span', async () => {
96
+ const teardown = instrumented();
97
+ try {
98
+ const client = new StubModels();
99
+ client.apiClient = { isVertexAI: () => false };
100
+ const span = await withActiveSpan(() =>
101
+ client.generateContentInternal({ model: 'tts', audioOutput: true })
102
+ );
103
+ assert.equal(span.attributes['gen_ai.system'], 'gemini');
104
+ assert.equal(span.attributes['gen_ai.usage.output_tokens_details.audio_tokens'], 17);
105
+ assert.equal(span.attributes['gen_ai.usage.input_tokens_details.audio_tokens'], undefined);
106
+ } finally {
107
+ teardown();
108
+ }
109
+ });
110
+
111
+ test('stamps provider vertex_ai and audio input tokens', async () => {
112
+ const teardown = instrumented();
113
+ try {
114
+ const client = new StubModels();
115
+ client.apiClient = { isVertexAI: () => true };
116
+ const span = await withActiveSpan(() =>
117
+ client.generateContentInternal({ model: 'm', audioInput: true })
118
+ );
119
+ assert.equal(span.attributes['gen_ai.system'], 'vertex_ai');
120
+ assert.equal(span.attributes['gen_ai.usage.input_tokens_details.audio_tokens'], 9);
121
+ } finally {
122
+ teardown();
123
+ }
124
+ });
125
+
126
+ test('no audio token attributes for a text-only call', async () => {
127
+ const teardown = instrumented();
128
+ try {
129
+ const client = new StubModels();
130
+ client.apiClient = { isVertexAI: () => false };
131
+ const span = await withActiveSpan(() => client.generateContentInternal({ model: 'm' }));
132
+ assert.equal(span.attributes['gen_ai.usage.output_tokens_details.audio_tokens'], undefined);
133
+ assert.equal(span.attributes['gen_ai.usage.cache_read_input_tokens'], undefined);
134
+ assert.equal(span.attributes['gen_ai.system'], 'gemini');
135
+ } finally {
136
+ teardown();
137
+ }
138
+ });
139
+
140
+ test('stamps cached-content tokens on the active span', async () => {
141
+ const teardown = instrumented();
142
+ try {
143
+ const client = new StubModels();
144
+ client.apiClient = { isVertexAI: () => false };
145
+ const span = await withActiveSpan(() =>
146
+ client.generateContentInternal({ model: 'm', cached: 128 })
147
+ );
148
+ assert.equal(span.attributes['gen_ai.usage.cache_read_input_tokens'], 128);
149
+ assert.equal(
150
+ span.attributes['gen_ai.usage.input_tokens_details.cached_audio_tokens'],
151
+ undefined
152
+ );
153
+ } finally {
154
+ teardown();
155
+ }
156
+ });
157
+
158
+ test('splits cached tokens into audio and text remainder by modality', async () => {
159
+ const teardown = instrumented();
160
+ try {
161
+ const client = new StubModels();
162
+ client.apiClient = { isVertexAI: () => false };
163
+ // 200 cached tokens, 50 of them audio: audio is billed separately, the rest
164
+ // stays as text cache-read so the two never double-count.
165
+ const span = await withActiveSpan(() =>
166
+ client.generateContentInternal({
167
+ model: 'm',
168
+ cached: 200,
169
+ cacheDetails: [
170
+ { modality: 'TEXT', tokenCount: 150 },
171
+ { modality: 'AUDIO', tokenCount: 50 },
172
+ ],
173
+ })
174
+ );
175
+ assert.equal(span.attributes['gen_ai.usage.input_tokens_details.cached_audio_tokens'], 50);
176
+ assert.equal(span.attributes['gen_ai.usage.cache_read_input_tokens'], 150);
177
+ } finally {
178
+ teardown();
179
+ }
180
+ });
181
+
182
+ test('streaming sets provider only and uninstrument restores methods', async () => {
183
+ const teardown = instrumented();
184
+ try {
185
+ const client = new StubModels();
186
+ client.apiClient = { isVertexAI: () => true };
187
+ const span = await withActiveSpan(() => client.generateContentStreamInternal({ model: 'm' }));
188
+ assert.equal(span.attributes['gen_ai.system'], 'vertex_ai');
189
+ } finally {
190
+ teardown();
191
+ }
192
+ // After teardown the prototype method is restored.
193
+ const client = new StubModels();
194
+ assert.equal(typeof client.generateContentInternal, 'function');
195
+ });
196
+
197
+ test('streaming re-yields the user chunks unchanged and stamps cache off the stream', async () => {
198
+ const teardown = instrumented();
199
+ try {
200
+ const client = new StubModels();
201
+ client.apiClient = { isVertexAI: () => false };
202
+ const collected = [];
203
+ const span = await withActiveSpan(async () => {
204
+ const stream = await client.generateContentStreamInternal({ model: 'm' });
205
+ for await (const chunk of stream) collected.push(chunk);
206
+ });
207
+ // Same count, order, and object identity: observeCache re-yields each chunk
208
+ // by reference — no telemetry collision with the caller's payload.
209
+ assert.equal(collected.length, STREAM_CHUNKS.length);
210
+ collected.forEach((chunk, index) => {
211
+ assert.equal(chunk, STREAM_CHUNKS[index]);
212
+ assert.equal(chunk.text, STREAM_CHUNKS[index].text);
213
+ });
214
+ // The wrapper still ran: cache tokens from the final chunk landed on the span.
215
+ assert.equal(span.attributes['gen_ai.usage.cache_read_input_tokens'], 32);
216
+ } finally {
217
+ teardown();
218
+ }
219
+ });
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // Tests for the bundled OpenAI audio instrumentor.
4
+ //
5
+ // The real `openai` audio resource classes are stubbed with `create` methods
6
+ // that mirror the SDK's shapes — Text-to-Speech resolves to the audio bytes and
7
+ // Speech-to-Text to a transcription carrying `duration` — so the instrumentor
8
+ // can be exercised without network access or the dependency installed.
9
+
10
+ const test = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const Module = require('node:module');
13
+
14
+ const { trace, SpanStatusCode } = require('@opentelemetry/api');
15
+ const {
16
+ BasicTracerProvider,
17
+ InMemorySpanExporter,
18
+ SimpleSpanProcessor,
19
+ } = require('@opentelemetry/sdk-trace-base');
20
+
21
+ const {
22
+ OpenAIAudioInstrumentor,
23
+ } = require('../../../src/instrumentation/openai/get_audio_telemetry');
24
+
25
+ const SPEECH_MODULE = 'openai/resources/audio/speech';
26
+ const TRANSCRIPTIONS_MODULE = 'openai/resources/audio/transcriptions';
27
+
28
+ const exporter = new InMemorySpanExporter();
29
+ trace.setGlobalTracerProvider(
30
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
31
+ );
32
+
33
+ class StubSpeech {
34
+ create(body = {}) {
35
+ if (body.input === 'boom') return Promise.reject(new Error('api error'));
36
+ return Promise.resolve(Buffer.from('audio-bytes'));
37
+ }
38
+ }
39
+
40
+ class StubTranscriptions {
41
+ create(body = {}) {
42
+ if (body.model === 'boom') return Promise.reject(new Error('api error'));
43
+ return Promise.resolve({ duration: 12.4, text: 'hello' });
44
+ }
45
+ }
46
+
47
+ // Stub the openai audio resource modules, install the instrumentor + the
48
+ // in-memory exporter, and return a teardown that restores everything.
49
+ function instrumented() {
50
+ const stubs = {
51
+ [SPEECH_MODULE]: { Speech: StubSpeech },
52
+ [TRANSCRIPTIONS_MODULE]: { Transcriptions: StubTranscriptions },
53
+ };
54
+ const origResolve = Module._resolveFilename;
55
+ Module._resolveFilename = function patched(request, ...rest) {
56
+ if (request in stubs) return request;
57
+ return origResolve.call(this, request, ...rest);
58
+ };
59
+ for (const [moduleId, exports] of Object.entries(stubs)) {
60
+ require.cache[moduleId] = { id: moduleId, filename: moduleId, loaded: true, exports };
61
+ }
62
+
63
+ const instrumentor = new OpenAIAudioInstrumentor();
64
+ instrumentor.instrument();
65
+
66
+ return () => {
67
+ instrumentor.uninstrument();
68
+ Module._resolveFilename = origResolve;
69
+ for (const moduleId of Object.keys(stubs)) delete require.cache[moduleId];
70
+ exporter.reset();
71
+ };
72
+ }
73
+
74
+ test('speech emits genai span with char count', async () => {
75
+ const teardown = instrumented();
76
+ try {
77
+ const client = new StubSpeech();
78
+ assert.deepEqual(
79
+ await client.create({ input: 'hello world', model: 'tts-1', voice: 'alloy' }),
80
+ Buffer.from('audio-bytes')
81
+ );
82
+
83
+ const [span] = exporter.getFinishedSpans();
84
+ assert.equal(span.name, 'text_to_speech');
85
+ assert.equal(span.attributes['gen_ai.system'], 'openai');
86
+ assert.equal(span.attributes['gen_ai.operation.name'], 'text_to_speech');
87
+ assert.equal(span.attributes['gen_ai.request.model'], 'tts-1');
88
+ assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello world'.length);
89
+ assert.equal(span.attributes['gen_ai.usage.input_seconds'], undefined);
90
+ assert.equal(span.status.code, SpanStatusCode.UNSET);
91
+ } finally {
92
+ teardown();
93
+ }
94
+ });
95
+
96
+ test('speech records error status on failure', async () => {
97
+ const teardown = instrumented();
98
+ try {
99
+ const client = new StubSpeech();
100
+ await assert.rejects(() => client.create({ input: 'boom', model: 'tts-1' }), /api error/);
101
+
102
+ const [span] = exporter.getFinishedSpans();
103
+ assert.equal(span.status.code, SpanStatusCode.ERROR);
104
+ } finally {
105
+ teardown();
106
+ }
107
+ });
108
+
109
+ test('transcriptions emits span with audio duration', async () => {
110
+ const teardown = instrumented();
111
+ try {
112
+ const client = new StubTranscriptions();
113
+ const result = await client.create({ file: Buffer.from('audio'), model: 'whisper-1' });
114
+ assert.equal(result.duration, 12.4);
115
+
116
+ const [span] = exporter.getFinishedSpans();
117
+ assert.equal(span.name, 'speech_to_text');
118
+ assert.equal(span.attributes['gen_ai.system'], 'openai');
119
+ assert.equal(span.attributes['gen_ai.operation.name'], 'speech_to_text');
120
+ assert.equal(span.attributes['gen_ai.request.model'], 'whisper-1');
121
+ assert.equal(span.attributes['gen_ai.usage.input_seconds'], 12.4);
122
+ assert.equal(span.attributes['gen_ai.usage.input_characters'], undefined);
123
+ assert.equal(span.status.code, SpanStatusCode.UNSET);
124
+ } finally {
125
+ teardown();
126
+ }
127
+ });
128
+
129
+ test('transcriptions records error status on failure', async () => {
130
+ const teardown = instrumented();
131
+ try {
132
+ const client = new StubTranscriptions();
133
+ await assert.rejects(
134
+ () => client.create({ file: Buffer.from('audio'), model: 'boom' }),
135
+ /api error/
136
+ );
137
+
138
+ const [span] = exporter.getFinishedSpans();
139
+ assert.equal(span.status.code, SpanStatusCode.ERROR);
140
+ } finally {
141
+ teardown();
142
+ }
143
+ });
144
+
145
+ test('uninstrument restores original methods', async () => {
146
+ const teardown = instrumented();
147
+ try {
148
+ const client = new StubSpeech();
149
+ await client.create({ input: 'x', model: 'tts-1' });
150
+ assert.equal(exporter.getFinishedSpans().length, 1);
151
+ } finally {
152
+ teardown();
153
+ }
154
+ });
155
+
156
+ test('system is azure when client targets azure', async () => {
157
+ const teardown = instrumented();
158
+ try {
159
+ const client = new StubSpeech();
160
+ // Azure is reached via the same openai SDK; the vendor is read off the
161
+ // client base URL so the span is attributed to azure.ai.openai, not openai.
162
+ client._client = { baseURL: 'https://acme.openai.azure.com/' };
163
+ await client.create({ input: 'hi', model: 'tts-1' });
164
+
165
+ const [span] = exporter.getFinishedSpans();
166
+ assert.equal(span.attributes['gen_ai.system'], 'azure.ai.openai');
167
+ } finally {
168
+ teardown();
169
+ }
170
+ });
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // Tests for the OpenAI non-streaming cached-prompt token complement. It wraps the
4
+ // primary instrumentor's `_endSpan({ span, type, result })` to stamp
5
+ // `gen_ai.usage.cache_read_input_tokens` from `usage.prompt_tokens_details.cached_tokens`.
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+
10
+ const {
11
+ OpenAIPromptCachingInstrumentor,
12
+ } = require('../../../src/instrumentation/openai/get_cache_and_reasoning_telemetry');
13
+
14
+ function fakeSpan() {
15
+ const attrs = {};
16
+ return {
17
+ attrs,
18
+ isRecording: () => true,
19
+ setAttribute(key, value) {
20
+ attrs[key] = value;
21
+ },
22
+ };
23
+ }
24
+
25
+ test('stamps cache_read_input_tokens and still calls the original _endSpan', () => {
26
+ const calls = [];
27
+ const instrumentation = {
28
+ _endSpan(args) {
29
+ calls.push(args);
30
+ },
31
+ };
32
+ new OpenAIPromptCachingInstrumentor().instrument(instrumentation);
33
+
34
+ const span = fakeSpan();
35
+ instrumentation._endSpan({
36
+ span,
37
+ type: 'chat',
38
+ result: { usage: { prompt_tokens: 1600, prompt_tokens_details: { cached_tokens: 1536 } } },
39
+ });
40
+
41
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], 1536);
42
+ assert.equal(calls.length, 1);
43
+ });
44
+
45
+ test('sets nothing when there are no cached tokens', () => {
46
+ const instrumentation = { _endSpan() {} };
47
+ new OpenAIPromptCachingInstrumentor().instrument(instrumentation);
48
+
49
+ const span = fakeSpan();
50
+ instrumentation._endSpan({ span, type: 'chat', result: { usage: { prompt_tokens: 10 } } });
51
+
52
+ assert.equal(span.attrs['gen_ai.usage.cache_read_input_tokens'], undefined);
53
+ });
54
+
55
+ test('stamps reasoning_tokens from completion_tokens_details', () => {
56
+ const instrumentation = { _endSpan() {} };
57
+ new OpenAIPromptCachingInstrumentor().instrument(instrumentation);
58
+
59
+ const span = fakeSpan();
60
+ instrumentation._endSpan({
61
+ span,
62
+ type: 'chat',
63
+ result: { usage: { output_tokens: 115, completion_tokens_details: { reasoning_tokens: 64 } } },
64
+ });
65
+
66
+ assert.equal(span.attrs['gen_ai.usage.reasoning_tokens'], 64);
67
+ });
68
+
69
+ test('is a no-op when the instrumentor has no _endSpan', () => {
70
+ const instrumentation = {};
71
+ new OpenAIPromptCachingInstrumentor().instrument(instrumentation);
72
+ assert.equal(instrumentation._endSpan, undefined);
73
+ });
74
+
75
+ test('uninstrument restores the original _endSpan', () => {
76
+ const original = () => {};
77
+ const instrumentation = { _endSpan: original };
78
+ const instrumentor = new OpenAIPromptCachingInstrumentor();
79
+ instrumentor.instrument(instrumentation);
80
+ assert.notEqual(instrumentation._endSpan, original);
81
+ instrumentor.uninstrument();
82
+ assert.equal(instrumentation._endSpan, original);
83
+ });