weflayr 0.20.5 → 0.20.6
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
CHANGED
|
@@ -38,6 +38,7 @@ export const AiSdk: {
|
|
|
38
38
|
readonly TOGETHER: 'together';
|
|
39
39
|
readonly GOOGLE_VERTEX: 'google-vertex';
|
|
40
40
|
readonly GOOGLE_GENAI: 'google-genai';
|
|
41
|
+
readonly ELEVENLABS: 'elevenlabs';
|
|
41
42
|
};
|
|
42
43
|
export type AiSdk = (typeof AiSdk)[keyof typeof AiSdk];
|
|
43
44
|
|
|
@@ -55,6 +56,7 @@ export const AIProviderName: {
|
|
|
55
56
|
readonly TOGETHER: 'together';
|
|
56
57
|
readonly GOOGLE_VERTEX: 'google-vertex';
|
|
57
58
|
readonly GOOGLE_GENAI: 'google-genai';
|
|
59
|
+
readonly ELEVENLABS: 'elevenlabs';
|
|
58
60
|
readonly VERCEL_AI_GATEWAY: 'vercel_ai_gateway';
|
|
59
61
|
};
|
|
60
62
|
export type AIProviderName = (typeof AIProviderName)[keyof typeof AIProviderName];
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weflayr",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.6",
|
|
4
4
|
"description": "Weflayr Node.js SDK — instrument any LLM client via JS Proxy",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "node --test tests/index.test.js",
|
|
8
|
+
"test": "node --test tests/index.test.js tests/instrumentation/elevenlabs.test.js",
|
|
9
9
|
"lint": "eslint src tests",
|
|
10
10
|
"lint:fix": "eslint src tests --fix",
|
|
11
11
|
"format": "prettier --write src tests",
|
package/src/auto-instrument.js
CHANGED
|
@@ -15,6 +15,7 @@ const AiSdk = Object.freeze({
|
|
|
15
15
|
TOGETHER: 'together',
|
|
16
16
|
GOOGLE_VERTEX: 'google-vertex',
|
|
17
17
|
GOOGLE_GENAI: 'google-genai',
|
|
18
|
+
ELEVENLABS: 'elevenlabs',
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
// Allowed values for `propagateMetadata({ providerName })`: every SDK the
|
|
@@ -35,6 +36,7 @@ const REGISTRY = {
|
|
|
35
36
|
[AiSdk.TOGETHER]: '@traceloop/instrumentation-together',
|
|
36
37
|
[AiSdk.GOOGLE_VERTEX]: '@traceloop/instrumentation-vertexai',
|
|
37
38
|
[AiSdk.GOOGLE_GENAI]: '@traceloop/instrumentation-google-generativeai',
|
|
39
|
+
[AiSdk.ELEVENLABS]: '@elevenlabs/elevenlabs-js',
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
const SETUP_OPTION_KEYS = [
|
|
@@ -65,6 +67,15 @@ async function _instrumentAiSdk(aiSdk) {
|
|
|
65
67
|
}
|
|
66
68
|
const pkg = REGISTRY[aiSdk];
|
|
67
69
|
|
|
70
|
+
if (aiSdk === AiSdk.ELEVENLABS) {
|
|
71
|
+
// ElevenLabs has no OTel/traceloop instrumentor, so
|
|
72
|
+
// use the hand-written one bundled with weflayr.
|
|
73
|
+
const { ElevenLabsInstrumentor } = require('./instrumentation/elevenlabs');
|
|
74
|
+
new ElevenLabsInstrumentor().instrument();
|
|
75
|
+
debug('autoInstrument: registered aiSdk=%s package=%s', aiSdk, pkg);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
let instrumentationPkg;
|
|
69
80
|
try {
|
|
70
81
|
instrumentationPkg = require(pkg);
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hand-written OTel instrumentor for the ElevenLabs audio clients.
|
|
5
|
+
*
|
|
6
|
+
* No official OpenTelemetry instrumentor exists for the ElevenLabs SDK (it's
|
|
7
|
+
* audio, not an LLM), so this ships inside weflayr. It wraps the Text-to-Speech
|
|
8
|
+
* and Speech-to-Text entrypoints to emit one `gen_ai.*` span per call, which
|
|
9
|
+
* flows through the standard span processor -> exporter with no new transport code.
|
|
10
|
+
*
|
|
11
|
+
* The billable unit differs by API, so each span records the one ElevenLabs bills:
|
|
12
|
+
* - Text-to-Speech is billed per input character -> `gen_ai.usage.input_characters`.
|
|
13
|
+
* - Speech-to-Text is billed per second of audio -> `gen_ai.usage.input_seconds`.
|
|
14
|
+
*
|
|
15
|
+
* Entrypoints come in three result shapes, independent of which API they belong to:
|
|
16
|
+
* - *byte stream* methods resolve to a `ReadableStream` of audio chunks;
|
|
17
|
+
* - *object stream* methods resolve to an async-iterable of chunk objects;
|
|
18
|
+
* - *unary* methods resolve to the full response.
|
|
19
|
+
* The HTTP request -- plus any error -- only happens while a stream is awaited
|
|
20
|
+
* and consumed, so the span is started eagerly (to capture the
|
|
21
|
+
* `propagateMetadata` context active at the call site) and ended once the result
|
|
22
|
+
* is exhausted (streams) or returned (unary), giving an `elapsed_ms` that covers
|
|
23
|
+
* the whole call. The per-second billable unit is read off the STT response.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { trace, SpanStatusCode } = require('@opentelemetry/api');
|
|
27
|
+
|
|
28
|
+
const TRACER_NAME = 'weflayr.instrumentation.elevenlabs';
|
|
29
|
+
const SYSTEM = 'elevenlabs';
|
|
30
|
+
const TTS_OPERATION = 'text_to_speech';
|
|
31
|
+
const STT_OPERATION = 'speech_to_text';
|
|
32
|
+
|
|
33
|
+
function _startSpan(operation, request) {
|
|
34
|
+
const span = trace.getTracer(TRACER_NAME).startSpan(operation);
|
|
35
|
+
span.setAttribute('gen_ai.system', SYSTEM);
|
|
36
|
+
span.setAttribute('gen_ai.operation.name', operation);
|
|
37
|
+
// `modelId` lives on the request body passed to every audio entrypoint.
|
|
38
|
+
const modelId = request && request.modelId;
|
|
39
|
+
if (modelId) {
|
|
40
|
+
span.setAttribute('gen_ai.request.model', modelId);
|
|
41
|
+
}
|
|
42
|
+
return span;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _setInputCharacters(span, request) {
|
|
46
|
+
// `text` lives on the request body of every TTS entrypoint; the billable unit
|
|
47
|
+
// is its length.
|
|
48
|
+
span.setAttribute('gen_ai.usage.input_characters', ((request && request.text) || '').length);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _setAudioDuration(span, response) {
|
|
52
|
+
// The transcription response exposes the transcribed audio length in seconds.
|
|
53
|
+
const durationSecs = response && response.audioDurationSecs;
|
|
54
|
+
if (durationSecs != null) {
|
|
55
|
+
span.setAttribute('gen_ai.usage.input_seconds', durationSecs);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _recordError(span, error) {
|
|
60
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
|
|
61
|
+
span.recordException(error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Proxy a `ReadableStream` of audio bytes so the span ends -- and any error is
|
|
65
|
+
// recorded -- once the whole stream has been consumed (or the consumer cancels).
|
|
66
|
+
// The span ends exactly once across every exit path.
|
|
67
|
+
function _consumeByteStream(span, responsePromise) {
|
|
68
|
+
let ended = false;
|
|
69
|
+
const end = () => {
|
|
70
|
+
if (ended) return;
|
|
71
|
+
ended = true;
|
|
72
|
+
span.end();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return responsePromise.then(
|
|
76
|
+
(audioStream) => {
|
|
77
|
+
const reader = audioStream.getReader();
|
|
78
|
+
return new ReadableStream({
|
|
79
|
+
async pull(controller) {
|
|
80
|
+
try {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) {
|
|
83
|
+
end();
|
|
84
|
+
controller.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
controller.enqueue(value);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
_recordError(span, error);
|
|
90
|
+
end();
|
|
91
|
+
controller.error(error);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
cancel(reason) {
|
|
95
|
+
end();
|
|
96
|
+
return reader.cancel(reason);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
(error) => {
|
|
101
|
+
_recordError(span, error);
|
|
102
|
+
end();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Proxy an async-iterable of chunk objects (the streaming "with timestamps"
|
|
109
|
+
// variant) so the span ends once iteration completes.
|
|
110
|
+
function _consumeObjectStream(span, responsePromise) {
|
|
111
|
+
async function* iterate(stream) {
|
|
112
|
+
try {
|
|
113
|
+
for await (const chunk of stream) {
|
|
114
|
+
yield chunk;
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
_recordError(span, error);
|
|
118
|
+
throw error;
|
|
119
|
+
} finally {
|
|
120
|
+
span.end();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return responsePromise.then(
|
|
125
|
+
(stream) => iterate(stream),
|
|
126
|
+
(error) => {
|
|
127
|
+
_recordError(span, error);
|
|
128
|
+
span.end();
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Wrap a request/response entrypoint: the span spans the single call, and any
|
|
135
|
+
// response-derived billable unit is read off the result.
|
|
136
|
+
function _consumeUnary(span, responsePromise, setResponseAttributes) {
|
|
137
|
+
return responsePromise.then(
|
|
138
|
+
(response) => {
|
|
139
|
+
if (setResponseAttributes) {
|
|
140
|
+
setResponseAttributes(span, response);
|
|
141
|
+
}
|
|
142
|
+
span.end();
|
|
143
|
+
return response;
|
|
144
|
+
},
|
|
145
|
+
(error) => {
|
|
146
|
+
_recordError(span, error);
|
|
147
|
+
span.end();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class ElevenLabsInstrumentor {
|
|
154
|
+
constructor() {
|
|
155
|
+
// [ownerPrototype, methodName, originalFunction] for uninstrument().
|
|
156
|
+
this._patched = [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
instrument() {
|
|
160
|
+
let TextToSpeechClient;
|
|
161
|
+
let SpeechToTextClient;
|
|
162
|
+
try {
|
|
163
|
+
({
|
|
164
|
+
TextToSpeechClient,
|
|
165
|
+
} = require('@elevenlabs/elevenlabs-js/api/resources/textToSpeech/client/Client'));
|
|
166
|
+
({
|
|
167
|
+
SpeechToTextClient,
|
|
168
|
+
} = require('@elevenlabs/elevenlabs-js/api/resources/speechToText/client/Client'));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
'weflayr.autoInstrument(ELEVENLABS): the @elevenlabs/elevenlabs-js client is not ' +
|
|
172
|
+
'installed. Install it with: npm install @elevenlabs/elevenlabs-js\n' +
|
|
173
|
+
`Underlying error: ${err.message}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// TTS — request body is the 2nd arg (after voice_id); billed per character.
|
|
178
|
+
this._wrap(TextToSpeechClient.prototype, 'convert', {
|
|
179
|
+
operation: TTS_OPERATION,
|
|
180
|
+
requestArgIndex: 1,
|
|
181
|
+
setRequestAttributes: _setInputCharacters,
|
|
182
|
+
consume: _consumeByteStream,
|
|
183
|
+
});
|
|
184
|
+
this._wrap(TextToSpeechClient.prototype, 'stream', {
|
|
185
|
+
operation: TTS_OPERATION,
|
|
186
|
+
requestArgIndex: 1,
|
|
187
|
+
setRequestAttributes: _setInputCharacters,
|
|
188
|
+
consume: _consumeByteStream,
|
|
189
|
+
});
|
|
190
|
+
this._wrap(TextToSpeechClient.prototype, 'streamWithTimestamps', {
|
|
191
|
+
operation: TTS_OPERATION,
|
|
192
|
+
requestArgIndex: 1,
|
|
193
|
+
setRequestAttributes: _setInputCharacters,
|
|
194
|
+
consume: _consumeObjectStream,
|
|
195
|
+
});
|
|
196
|
+
this._wrap(TextToSpeechClient.prototype, 'convertWithTimestamps', {
|
|
197
|
+
operation: TTS_OPERATION,
|
|
198
|
+
requestArgIndex: 1,
|
|
199
|
+
setRequestAttributes: _setInputCharacters,
|
|
200
|
+
consume: (span, responsePromise) => _consumeUnary(span, responsePromise, null),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// STT — request body is the 1st arg; billed per second of audio.
|
|
204
|
+
this._wrap(SpeechToTextClient.prototype, 'convert', {
|
|
205
|
+
operation: STT_OPERATION,
|
|
206
|
+
requestArgIndex: 0,
|
|
207
|
+
setRequestAttributes: null,
|
|
208
|
+
consume: (span, responsePromise) => _consumeUnary(span, responsePromise, _setAudioDuration),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
uninstrument() {
|
|
213
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
214
|
+
owner[methodName] = original;
|
|
215
|
+
}
|
|
216
|
+
this._patched = [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_wrap(owner, methodName, { operation, requestArgIndex, setRequestAttributes, consume }) {
|
|
220
|
+
const original = owner[methodName];
|
|
221
|
+
|
|
222
|
+
function wrapper(...args) {
|
|
223
|
+
const request = args[requestArgIndex];
|
|
224
|
+
const span = _startSpan(operation, request);
|
|
225
|
+
if (setRequestAttributes) {
|
|
226
|
+
setRequestAttributes(span, request);
|
|
227
|
+
}
|
|
228
|
+
return consume(span, original.apply(this, args));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
owner[methodName] = wrapper;
|
|
232
|
+
this._patched.push([owner, methodName, original]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { ElevenLabsInstrumentor };
|
package/src/otel/span-filter.js
CHANGED
|
@@ -29,6 +29,7 @@ const KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
|
29
29
|
'opentelemetry.instrumentation.openai_v2',
|
|
30
30
|
'opentelemetry.instrumentation.vertex_ai',
|
|
31
31
|
'opentelemetry.instrumentation.vertexai',
|
|
32
|
+
'weflayr.instrumentation.elevenlabs',
|
|
32
33
|
'strands-agents',
|
|
33
34
|
'vllm',
|
|
34
35
|
'@traceloop/instrumentation-openai',
|
|
@@ -0,0 +1,242 @@
|
|
|
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 { ElevenLabsInstrumentor } = require('../../src/instrumentation/elevenlabs');
|
|
24
|
+
|
|
25
|
+
const TTS_CLIENT_MODULE = '@elevenlabs/elevenlabs-js/api/resources/textToSpeech/client/Client';
|
|
26
|
+
const STT_CLIENT_MODULE = '@elevenlabs/elevenlabs-js/api/resources/speechToText/client/Client';
|
|
27
|
+
|
|
28
|
+
// The instrumentor emits via the global tracer provider. node's test runner
|
|
29
|
+
// isolates each test file in its own process, so registering one provider +
|
|
30
|
+
// in-memory exporter for the whole file is safe.
|
|
31
|
+
const exporter = new InMemorySpanExporter();
|
|
32
|
+
trace.setGlobalTracerProvider(
|
|
33
|
+
new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function readableOf(chunks) {
|
|
37
|
+
let index = 0;
|
|
38
|
+
return new ReadableStream({
|
|
39
|
+
pull(controller) {
|
|
40
|
+
if (index < chunks.length) controller.enqueue(chunks[index++]);
|
|
41
|
+
else controller.close();
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function failingReadable(error) {
|
|
47
|
+
return new ReadableStream({
|
|
48
|
+
pull() {
|
|
49
|
+
throw error;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asyncIterableOf(chunks) {
|
|
55
|
+
return {
|
|
56
|
+
async *[Symbol.asyncIterator]() {
|
|
57
|
+
for (const chunk of chunks) yield chunk;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class StubTextToSpeechClient {
|
|
63
|
+
convert(_voiceId, request = {}) {
|
|
64
|
+
if (request.text === 'boom') return Promise.resolve(failingReadable(new Error('api error')));
|
|
65
|
+
return Promise.resolve(readableOf([Buffer.from('a'), Buffer.from('b')]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stream(_voiceId, _request = {}) {
|
|
69
|
+
return Promise.resolve(readableOf([Buffer.from('x')]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
streamWithTimestamps(_voiceId, _request = {}) {
|
|
73
|
+
return Promise.resolve(asyncIterableOf([{ audioBase64: 'eA==' }]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
convertWithTimestamps(_voiceId, _request = {}) {
|
|
77
|
+
return Promise.resolve({ audioBase64: 'YQ==' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class StubSpeechToTextClient {
|
|
82
|
+
convert(request = {}) {
|
|
83
|
+
if (request.modelId === 'boom') return Promise.reject(new Error('api error'));
|
|
84
|
+
return Promise.resolve({ audioDurationSecs: 12.4, text: 'hello' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function drain(stream) {
|
|
89
|
+
const reader = stream.getReader();
|
|
90
|
+
const chunks = [];
|
|
91
|
+
for (;;) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done) break;
|
|
94
|
+
chunks.push(value);
|
|
95
|
+
}
|
|
96
|
+
return chunks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function drainIterable(iterable) {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
for await (const chunk of iterable) chunks.push(chunk);
|
|
102
|
+
return chunks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Stub the elevenlabs client modules, install the instrumentor + the in-memory
|
|
106
|
+
// exporter, and return a teardown that restores everything.
|
|
107
|
+
function instrumented() {
|
|
108
|
+
const stubs = {
|
|
109
|
+
[TTS_CLIENT_MODULE]: { TextToSpeechClient: StubTextToSpeechClient },
|
|
110
|
+
[STT_CLIENT_MODULE]: { SpeechToTextClient: StubSpeechToTextClient },
|
|
111
|
+
};
|
|
112
|
+
const origResolve = Module._resolveFilename;
|
|
113
|
+
Module._resolveFilename = function patched(request, ...rest) {
|
|
114
|
+
if (request in stubs) return request;
|
|
115
|
+
return origResolve.call(this, request, ...rest);
|
|
116
|
+
};
|
|
117
|
+
for (const [moduleId, exports] of Object.entries(stubs)) {
|
|
118
|
+
require.cache[moduleId] = { id: moduleId, filename: moduleId, loaded: true, exports };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const instrumentor = new ElevenLabsInstrumentor();
|
|
122
|
+
instrumentor.instrument();
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
instrumentor.uninstrument();
|
|
126
|
+
Module._resolveFilename = origResolve;
|
|
127
|
+
for (const moduleId of Object.keys(stubs)) delete require.cache[moduleId];
|
|
128
|
+
exporter.reset();
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test('convert emits genai span with char count', async () => {
|
|
133
|
+
const teardown = instrumented();
|
|
134
|
+
try {
|
|
135
|
+
const client = new StubTextToSpeechClient();
|
|
136
|
+
const stream = await client.convert('voice', { text: 'hello world', modelId: 'eleven_v2' });
|
|
137
|
+
assert.deepEqual(await drain(stream), [Buffer.from('a'), Buffer.from('b')]);
|
|
138
|
+
|
|
139
|
+
const [span] = exporter.getFinishedSpans();
|
|
140
|
+
assert.equal(span.name, 'text_to_speech');
|
|
141
|
+
assert.equal(span.attributes['gen_ai.system'], 'elevenlabs');
|
|
142
|
+
assert.equal(span.attributes['gen_ai.operation.name'], 'text_to_speech');
|
|
143
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'eleven_v2');
|
|
144
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello world'.length);
|
|
145
|
+
assert.equal(span.status.code, SpanStatusCode.UNSET);
|
|
146
|
+
} finally {
|
|
147
|
+
teardown();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('convert records error status on failure', async () => {
|
|
152
|
+
const teardown = instrumented();
|
|
153
|
+
try {
|
|
154
|
+
const client = new StubTextToSpeechClient();
|
|
155
|
+
const stream = await client.convert('voice', { text: 'boom', modelId: 'm' });
|
|
156
|
+
await assert.rejects(() => drain(stream), /api error/);
|
|
157
|
+
|
|
158
|
+
const [span] = exporter.getFinishedSpans();
|
|
159
|
+
assert.equal(span.status.code, SpanStatusCode.ERROR);
|
|
160
|
+
} finally {
|
|
161
|
+
teardown();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('convert with timestamps emits span with char count', async () => {
|
|
166
|
+
const teardown = instrumented();
|
|
167
|
+
try {
|
|
168
|
+
const client = new StubTextToSpeechClient();
|
|
169
|
+
// The unary timestamp variant resolves to a single response, not a stream.
|
|
170
|
+
const result = await client.convertWithTimestamps('voice', { text: 'hello', modelId: 'm' });
|
|
171
|
+
assert.equal(result.audioBase64, 'YQ==');
|
|
172
|
+
|
|
173
|
+
const [span] = exporter.getFinishedSpans();
|
|
174
|
+
assert.equal(span.name, 'text_to_speech');
|
|
175
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello'.length);
|
|
176
|
+
assert.equal(span.attributes['gen_ai.usage.input_seconds'], undefined);
|
|
177
|
+
} finally {
|
|
178
|
+
teardown();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('stream with timestamps emits span with char count', async () => {
|
|
183
|
+
const teardown = instrumented();
|
|
184
|
+
try {
|
|
185
|
+
const client = new StubTextToSpeechClient();
|
|
186
|
+
const stream = await client.streamWithTimestamps('voice', { text: 'hello', modelId: 'm' });
|
|
187
|
+
assert.deepEqual(await drainIterable(stream), [{ audioBase64: 'eA==' }]);
|
|
188
|
+
|
|
189
|
+
const [span] = exporter.getFinishedSpans();
|
|
190
|
+
assert.equal(span.name, 'text_to_speech');
|
|
191
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], 'hello'.length);
|
|
192
|
+
} finally {
|
|
193
|
+
teardown();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('uninstrument restores original methods', async () => {
|
|
198
|
+
const teardown = instrumented();
|
|
199
|
+
try {
|
|
200
|
+
const client = new StubTextToSpeechClient();
|
|
201
|
+
assert.deepEqual(await drain(await client.stream('voice', { text: 'x' })), [Buffer.from('x')]);
|
|
202
|
+
assert.equal(exporter.getFinishedSpans().length, 1);
|
|
203
|
+
} finally {
|
|
204
|
+
teardown();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('speech to text emits span with audio duration', async () => {
|
|
209
|
+
const teardown = instrumented();
|
|
210
|
+
try {
|
|
211
|
+
const client = new StubSpeechToTextClient();
|
|
212
|
+
const result = await client.convert({ modelId: 'scribe_v1', file: Buffer.from('audio') });
|
|
213
|
+
assert.equal(result.audioDurationSecs, 12.4);
|
|
214
|
+
|
|
215
|
+
const [span] = exporter.getFinishedSpans();
|
|
216
|
+
assert.equal(span.name, 'speech_to_text');
|
|
217
|
+
assert.equal(span.attributes['gen_ai.system'], 'elevenlabs');
|
|
218
|
+
assert.equal(span.attributes['gen_ai.operation.name'], 'speech_to_text');
|
|
219
|
+
assert.equal(span.attributes['gen_ai.request.model'], 'scribe_v1');
|
|
220
|
+
assert.equal(span.attributes['gen_ai.usage.input_seconds'], 12.4);
|
|
221
|
+
assert.equal(span.attributes['gen_ai.usage.input_characters'], undefined);
|
|
222
|
+
assert.equal(span.status.code, SpanStatusCode.UNSET);
|
|
223
|
+
} finally {
|
|
224
|
+
teardown();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('speech to text records error status on failure', async () => {
|
|
229
|
+
const teardown = instrumented();
|
|
230
|
+
try {
|
|
231
|
+
const client = new StubSpeechToTextClient();
|
|
232
|
+
await assert.rejects(
|
|
233
|
+
() => client.convert({ modelId: 'boom', file: Buffer.from('audio') }),
|
|
234
|
+
/api error/
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const [span] = exporter.getFinishedSpans();
|
|
238
|
+
assert.equal(span.status.code, SpanStatusCode.ERROR);
|
|
239
|
+
} finally {
|
|
240
|
+
teardown();
|
|
241
|
+
}
|
|
242
|
+
});
|