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.
- package/index.d.ts +2 -0
- package/package.json +2 -2
- package/src/auto-instrument.js +67 -23
- 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/elevenlabs/full_instrumentation.js +236 -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 +1 -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/full_instrumentation.test.js +244 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the bundled Cohere embeddings instrumentor.
|
|
4
|
+
//
|
|
5
|
+
// The cohere-ai v1 `CohereClient` and v2 `V2Client` classes are stubbed with an
|
|
6
|
+
// `embed` method that mirrors the SDK shape — resolving to a response carrying
|
|
7
|
+
// `meta.billedUnits.inputTokens` — so the instrumentor can be exercised without
|
|
8
|
+
// 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
|
+
CohereEmbeddingsInstrumentor,
|
|
23
|
+
} = require('../../../src/instrumentation/cohere/get_embeddings_telemetry');
|
|
24
|
+
|
|
25
|
+
const V1_MODULE = 'cohere-ai/Client';
|
|
26
|
+
const V2_MODULE = 'cohere-ai/api/resources/v2/client/Client';
|
|
27
|
+
|
|
28
|
+
const exporter = new InMemorySpanExporter();
|
|
29
|
+
trace.setGlobalTracerProvider(
|
|
30
|
+
new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
class StubCohereClient {
|
|
34
|
+
embed(request = {}) {
|
|
35
|
+
if (request.model === 'boom') return Promise.reject(new Error('api error'));
|
|
36
|
+
return Promise.resolve({ embeddings: [[0.1]], meta: { billedUnits: { inputTokens: 8 } } });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class StubV2Client {
|
|
41
|
+
embed(_request = {}) {
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
embeddings: { float: [[0.2]] },
|
|
44
|
+
meta: { billedUnits: { inputTokens: 5 } },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function instrumented() {
|
|
50
|
+
const stubs = {
|
|
51
|
+
[V1_MODULE]: { CohereClient: StubCohereClient },
|
|
52
|
+
[V2_MODULE]: { V2Client: StubV2Client },
|
|
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 CohereEmbeddingsInstrumentor();
|
|
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('v1 embed emits genai span with input tokens', async () => {
|
|
75
|
+
const teardown = instrumented();
|
|
76
|
+
try {
|
|
77
|
+
const client = new StubCohereClient();
|
|
78
|
+
const result = await client.embed({ model: 'embed-english-v3.0', texts: ['hi'] });
|
|
79
|
+
assert.equal(result.meta.billedUnits.inputTokens, 8);
|
|
80
|
+
|
|
81
|
+
const [span] = exporter.getFinishedSpans();
|
|
82
|
+
assert.equal(span.name, 'embeddings');
|
|
83
|
+
assert.equal(span.attributes['gen_ai.system'], 'Cohere');
|
|
84
|
+
assert.equal(span.attributes['gen_ai.operation.name'], 'embeddings');
|
|
85
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'embed-english-v3.0');
|
|
86
|
+
assert.equal(span.attributes['gen_ai.usage.input_tokens'], 8);
|
|
87
|
+
assert.equal(span.status.code, SpanStatusCode.UNSET);
|
|
88
|
+
} finally {
|
|
89
|
+
teardown();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('v2 embed emits genai span with input tokens', async () => {
|
|
94
|
+
const teardown = instrumented();
|
|
95
|
+
try {
|
|
96
|
+
const client = new StubV2Client();
|
|
97
|
+
await client.embed({ model: 'embed-v4.0', texts: ['hi'] });
|
|
98
|
+
|
|
99
|
+
const [span] = exporter.getFinishedSpans();
|
|
100
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'embed-v4.0');
|
|
101
|
+
assert.equal(span.attributes['gen_ai.usage.input_tokens'], 5);
|
|
102
|
+
} finally {
|
|
103
|
+
teardown();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('embed records error status on failure', async () => {
|
|
108
|
+
const teardown = instrumented();
|
|
109
|
+
try {
|
|
110
|
+
const client = new StubCohereClient();
|
|
111
|
+
await assert.rejects(() => client.embed({ model: 'boom', texts: ['x'] }), /api error/);
|
|
112
|
+
|
|
113
|
+
const [span] = exporter.getFinishedSpans();
|
|
114
|
+
assert.equal(span.status.code, SpanStatusCode.ERROR);
|
|
115
|
+
} finally {
|
|
116
|
+
teardown();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('uninstrument restores original methods', async () => {
|
|
121
|
+
const teardown = instrumented();
|
|
122
|
+
try {
|
|
123
|
+
const client = new StubCohereClient();
|
|
124
|
+
await client.embed({ model: 'embed-english-v3.0', texts: ['x'] });
|
|
125
|
+
assert.equal(exporter.getFinishedSpans().length, 1);
|
|
126
|
+
} finally {
|
|
127
|
+
teardown();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for the bundled ElevenLabs audio instrumentor.
|
|
4
|
+
//
|
|
5
|
+
// The real `@elevenlabs/elevenlabs-js` clients are stubbed with methods that
|
|
6
|
+
// mirror the SDK's shapes — the streaming TTS entrypoints (`convert` / `stream`)
|
|
7
|
+
// resolve to a ReadableStream, `streamWithTimestamps` to an async-iterable, and
|
|
8
|
+
// the unary `convertWithTimestamps` and Speech-to-Text `convert` to a response
|
|
9
|
+
// object — so the instrumentor can be exercised without network access or the
|
|
10
|
+
// dependency installed.
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const Module = require('node:module');
|
|
15
|
+
|
|
16
|
+
const { trace, SpanStatusCode } = require('@opentelemetry/api');
|
|
17
|
+
const {
|
|
18
|
+
BasicTracerProvider,
|
|
19
|
+
InMemorySpanExporter,
|
|
20
|
+
SimpleSpanProcessor,
|
|
21
|
+
} = require('@opentelemetry/sdk-trace-base');
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
ElevenLabsInstrumentor,
|
|
25
|
+
} = require('../../../src/instrumentation/elevenlabs/full_instrumentation');
|
|
26
|
+
|
|
27
|
+
const TTS_CLIENT_MODULE = '@elevenlabs/elevenlabs-js/api/resources/textToSpeech/client/Client';
|
|
28
|
+
const STT_CLIENT_MODULE = '@elevenlabs/elevenlabs-js/api/resources/speechToText/client/Client';
|
|
29
|
+
|
|
30
|
+
// The instrumentor emits via the global tracer provider. node's test runner
|
|
31
|
+
// isolates each test file in its own process, so registering one provider +
|
|
32
|
+
// in-memory exporter for the whole file is safe.
|
|
33
|
+
const exporter = new InMemorySpanExporter();
|
|
34
|
+
trace.setGlobalTracerProvider(
|
|
35
|
+
new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
function readableOf(chunks) {
|
|
39
|
+
let index = 0;
|
|
40
|
+
return new ReadableStream({
|
|
41
|
+
pull(controller) {
|
|
42
|
+
if (index < chunks.length) controller.enqueue(chunks[index++]);
|
|
43
|
+
else controller.close();
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function failingReadable(error) {
|
|
49
|
+
return new ReadableStream({
|
|
50
|
+
pull() {
|
|
51
|
+
throw error;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function asyncIterableOf(chunks) {
|
|
57
|
+
return {
|
|
58
|
+
async *[Symbol.asyncIterator]() {
|
|
59
|
+
for (const chunk of chunks) yield chunk;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class StubTextToSpeechClient {
|
|
65
|
+
convert(_voiceId, request = {}) {
|
|
66
|
+
if (request.text === 'boom') return Promise.resolve(failingReadable(new Error('api error')));
|
|
67
|
+
return Promise.resolve(readableOf([Buffer.from('a'), Buffer.from('b')]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stream(_voiceId, _request = {}) {
|
|
71
|
+
return Promise.resolve(readableOf([Buffer.from('x')]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
streamWithTimestamps(_voiceId, _request = {}) {
|
|
75
|
+
return Promise.resolve(asyncIterableOf([{ audioBase64: 'eA==' }]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
convertWithTimestamps(_voiceId, _request = {}) {
|
|
79
|
+
return Promise.resolve({ audioBase64: 'YQ==' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class StubSpeechToTextClient {
|
|
84
|
+
convert(request = {}) {
|
|
85
|
+
if (request.modelId === 'boom') return Promise.reject(new Error('api error'));
|
|
86
|
+
return Promise.resolve({ audioDurationSecs: 12.4, text: 'hello' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function drain(stream) {
|
|
91
|
+
const reader = stream.getReader();
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for (;;) {
|
|
94
|
+
const { done, value } = await reader.read();
|
|
95
|
+
if (done) break;
|
|
96
|
+
chunks.push(value);
|
|
97
|
+
}
|
|
98
|
+
return chunks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drainIterable(iterable) {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
for await (const chunk of iterable) chunks.push(chunk);
|
|
104
|
+
return chunks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Stub the elevenlabs client modules, install the instrumentor + the in-memory
|
|
108
|
+
// exporter, and return a teardown that restores everything.
|
|
109
|
+
function instrumented() {
|
|
110
|
+
const stubs = {
|
|
111
|
+
[TTS_CLIENT_MODULE]: { TextToSpeechClient: StubTextToSpeechClient },
|
|
112
|
+
[STT_CLIENT_MODULE]: { SpeechToTextClient: StubSpeechToTextClient },
|
|
113
|
+
};
|
|
114
|
+
const origResolve = Module._resolveFilename;
|
|
115
|
+
Module._resolveFilename = function patched(request, ...rest) {
|
|
116
|
+
if (request in stubs) return request;
|
|
117
|
+
return origResolve.call(this, request, ...rest);
|
|
118
|
+
};
|
|
119
|
+
for (const [moduleId, exports] of Object.entries(stubs)) {
|
|
120
|
+
require.cache[moduleId] = { id: moduleId, filename: moduleId, loaded: true, exports };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const instrumentor = new ElevenLabsInstrumentor();
|
|
124
|
+
instrumentor.instrument();
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
instrumentor.uninstrument();
|
|
128
|
+
Module._resolveFilename = origResolve;
|
|
129
|
+
for (const moduleId of Object.keys(stubs)) delete require.cache[moduleId];
|
|
130
|
+
exporter.reset();
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test('convert emits genai span with char count', async () => {
|
|
135
|
+
const teardown = instrumented();
|
|
136
|
+
try {
|
|
137
|
+
const client = new StubTextToSpeechClient();
|
|
138
|
+
const stream = await client.convert('voice', { text: 'hello world', modelId: 'eleven_v2' });
|
|
139
|
+
assert.deepEqual(await drain(stream), [Buffer.from('a'), Buffer.from('b')]);
|
|
140
|
+
|
|
141
|
+
const [span] = exporter.getFinishedSpans();
|
|
142
|
+
assert.equal(span.name, 'text_to_speech');
|
|
143
|
+
assert.equal(span.attributes['gen_ai.system'], 'elevenlabs');
|
|
144
|
+
assert.equal(span.attributes['gen_ai.operation.name'], 'text_to_speech');
|
|
145
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'eleven_v2');
|
|
146
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello world'.length);
|
|
147
|
+
assert.equal(span.status.code, SpanStatusCode.UNSET);
|
|
148
|
+
} finally {
|
|
149
|
+
teardown();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('convert records error status on failure', async () => {
|
|
154
|
+
const teardown = instrumented();
|
|
155
|
+
try {
|
|
156
|
+
const client = new StubTextToSpeechClient();
|
|
157
|
+
const stream = await client.convert('voice', { text: 'boom', modelId: 'm' });
|
|
158
|
+
await assert.rejects(() => drain(stream), /api error/);
|
|
159
|
+
|
|
160
|
+
const [span] = exporter.getFinishedSpans();
|
|
161
|
+
assert.equal(span.status.code, SpanStatusCode.ERROR);
|
|
162
|
+
} finally {
|
|
163
|
+
teardown();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('convert with timestamps emits span with char count', async () => {
|
|
168
|
+
const teardown = instrumented();
|
|
169
|
+
try {
|
|
170
|
+
const client = new StubTextToSpeechClient();
|
|
171
|
+
// The unary timestamp variant resolves to a single response, not a stream.
|
|
172
|
+
const result = await client.convertWithTimestamps('voice', { text: 'hello', modelId: 'm' });
|
|
173
|
+
assert.equal(result.audioBase64, 'YQ==');
|
|
174
|
+
|
|
175
|
+
const [span] = exporter.getFinishedSpans();
|
|
176
|
+
assert.equal(span.name, 'text_to_speech');
|
|
177
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello'.length);
|
|
178
|
+
assert.equal(span.attributes['gen_ai.usage.input_seconds'], undefined);
|
|
179
|
+
} finally {
|
|
180
|
+
teardown();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('stream with timestamps emits span with char count', async () => {
|
|
185
|
+
const teardown = instrumented();
|
|
186
|
+
try {
|
|
187
|
+
const client = new StubTextToSpeechClient();
|
|
188
|
+
const stream = await client.streamWithTimestamps('voice', { text: 'hello', modelId: 'm' });
|
|
189
|
+
assert.deepEqual(await drainIterable(stream), [{ audioBase64: 'eA==' }]);
|
|
190
|
+
|
|
191
|
+
const [span] = exporter.getFinishedSpans();
|
|
192
|
+
assert.equal(span.name, 'text_to_speech');
|
|
193
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello'.length);
|
|
194
|
+
} finally {
|
|
195
|
+
teardown();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('uninstrument restores original methods', async () => {
|
|
200
|
+
const teardown = instrumented();
|
|
201
|
+
try {
|
|
202
|
+
const client = new StubTextToSpeechClient();
|
|
203
|
+
assert.deepEqual(await drain(await client.stream('voice', { text: 'x' })), [Buffer.from('x')]);
|
|
204
|
+
assert.equal(exporter.getFinishedSpans().length, 1);
|
|
205
|
+
} finally {
|
|
206
|
+
teardown();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('speech to text emits span with audio duration', async () => {
|
|
211
|
+
const teardown = instrumented();
|
|
212
|
+
try {
|
|
213
|
+
const client = new StubSpeechToTextClient();
|
|
214
|
+
const result = await client.convert({ modelId: 'scribe_v1', file: Buffer.from('audio') });
|
|
215
|
+
assert.equal(result.audioDurationSecs, 12.4);
|
|
216
|
+
|
|
217
|
+
const [span] = exporter.getFinishedSpans();
|
|
218
|
+
assert.equal(span.name, 'speech_to_text');
|
|
219
|
+
assert.equal(span.attributes['gen_ai.system'], 'elevenlabs');
|
|
220
|
+
assert.equal(span.attributes['gen_ai.operation.name'], 'speech_to_text');
|
|
221
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'scribe_v1');
|
|
222
|
+
assert.equal(span.attributes['gen_ai.usage.input_seconds'], 12.4);
|
|
223
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], undefined);
|
|
224
|
+
assert.equal(span.status.code, SpanStatusCode.UNSET);
|
|
225
|
+
} finally {
|
|
226
|
+
teardown();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('speech to text records error status on failure', async () => {
|
|
231
|
+
const teardown = instrumented();
|
|
232
|
+
try {
|
|
233
|
+
const client = new StubSpeechToTextClient();
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() => client.convert({ modelId: 'boom', file: Buffer.from('audio') }),
|
|
236
|
+
/api error/
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const [span] = exporter.getFinishedSpans();
|
|
240
|
+
assert.equal(span.status.code, SpanStatusCode.ERROR);
|
|
241
|
+
} finally {
|
|
242
|
+
teardown();
|
|
243
|
+
}
|
|
244
|
+
});
|