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.
- package/index.d.ts +1 -1
- 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/get_streaming_stop_reason_telemetry.js +121 -0
- package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
- package/src/instrumentation/anthropic/set_telemetry_attributes.js +37 -0
- package/src/instrumentation/bedrock/get_cache_telemetry.js +104 -0
- package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
- package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
- package/src/instrumentation/bedrock/set_telemetry_attributes.js +36 -0
- package/src/instrumentation/cohere/get_chat_telemetry.js +88 -0
- package/src/instrumentation/cohere/get_embeddings_telemetry.js +107 -0
- package/src/instrumentation/cohere/set_telemetry_attributes.js +32 -0
- package/src/instrumentation/google/get_audio_telemetry.js +30 -0
- package/src/instrumentation/google/get_cache_telemetry.js +25 -0
- package/src/instrumentation/google/get_embeddings_telemetry.js +99 -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 +107 -0
- package/src/instrumentation/openai/get_streaming_usage.js +81 -0
- package/src/instrumentation/openai/set_telemetry_attributes.js +39 -0
- package/src/otel/exporter.js +1 -6
- package/src/otel/propagation.js +5 -0
- package/src/otel/span-filter.js +0 -5
- package/src/otel/span-processor.js +30 -1
- package/src/setup.js +1 -2
- package/tests/index.test.js +144 -44
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +158 -0
- package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
- package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
- package/tests/instrumentation/bedrock/get_cache_telemetry.test.js +102 -0
- package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
- package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
- package/tests/instrumentation/cohere/get_chat_telemetry.test.js +148 -0
- package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +132 -0
- package/tests/instrumentation/{elevenlabs.test.js → elevenlabs/full_instrumentation.test.js} +21 -5
- package/tests/instrumentation/google/get_embeddings_telemetry.test.js +128 -0
- package/tests/instrumentation/google/get_reasoning_telemetry.test.js +41 -0
- package/tests/instrumentation/google/set_telemetry_attributes.test.js +219 -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 +145 -0
- package/tests/instrumentation/openai/get_streaming_usage.test.js +105 -0
- package/src/otel/openai-streaming-usage.js +0 -60
- /package/src/instrumentation/{elevenlabs.js → elevenlabs/full_instrumentation.js} +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input message content for Bedrock invoke_model spans the instrumentor misses.
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-bedrock` parses the request body per model vendor,
|
|
7
|
+
* so a call whose model id has no recognizable vendor (e.g. an invalid model —
|
|
8
|
+
* exactly the error spans) gets no `gen_ai.input.messages`. Wrap the
|
|
9
|
+
* instrumentor's `_startSpan({ params })` to stamp the input messages from the
|
|
10
|
+
* request body whenever the instrumentor did not. The span processor strips the
|
|
11
|
+
* attribute when message-content capture is disabled.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function _contentToParts(content) {
|
|
15
|
+
if (typeof content === 'string') {
|
|
16
|
+
return [{ type: 'text', content }];
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(content)) {
|
|
19
|
+
return content
|
|
20
|
+
.filter((block) => block && typeof block.text === 'string')
|
|
21
|
+
.map((block) => ({ type: 'text', content: block.text }));
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Messages in the role+parts shape from an InvokeModel JSON body — the
|
|
27
|
+
// `messages` schema (Nova, Anthropic-on-Bedrock) or the flat `inputText` /
|
|
28
|
+
// `prompt` schemas (Titan, legacy).
|
|
29
|
+
function _inputMessagesFromRequestBody(rawBody) {
|
|
30
|
+
if (typeof rawBody !== 'string') return [];
|
|
31
|
+
let body;
|
|
32
|
+
try {
|
|
33
|
+
body = JSON.parse(rawBody);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (!body || typeof body !== 'object') return [];
|
|
38
|
+
if (Array.isArray(body.messages)) {
|
|
39
|
+
return body.messages
|
|
40
|
+
.filter((message) => message && typeof message === 'object')
|
|
41
|
+
.map((message) => ({
|
|
42
|
+
role: String(message.role || ''),
|
|
43
|
+
parts: _contentToParts(message.content),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
const prompt = body.inputText || body.prompt;
|
|
47
|
+
if (typeof prompt === 'string') {
|
|
48
|
+
return [{ role: 'user', parts: [{ type: 'text', content: prompt }] }];
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class BedrockInputMessageContentInstrumentor {
|
|
54
|
+
constructor() {
|
|
55
|
+
// [instrumentation, methodName, originalFunction] for uninstrument().
|
|
56
|
+
this._patched = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No-op (and never throws) if the instrumentor's internals have changed shape —
|
|
60
|
+
// the worst case is missing input messages, never a crash.
|
|
61
|
+
instrument(instrumentation) {
|
|
62
|
+
if (!instrumentation) return;
|
|
63
|
+
const original = instrumentation._startSpan;
|
|
64
|
+
if (typeof original !== 'function') return;
|
|
65
|
+
|
|
66
|
+
instrumentation._startSpan = function startSpanWithInputMessages(request) {
|
|
67
|
+
const span = original.call(this, request);
|
|
68
|
+
try {
|
|
69
|
+
const attributes = span && span.attributes;
|
|
70
|
+
if (attributes && attributes['gen_ai.input.messages'] == null) {
|
|
71
|
+
const body =
|
|
72
|
+
request && request.params && request.params.input && request.params.input.body;
|
|
73
|
+
const messages = _inputMessagesFromRequestBody(body);
|
|
74
|
+
if (messages.length) {
|
|
75
|
+
span.setAttribute('gen_ai.input.messages', JSON.stringify(messages));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Never break span creation over an enrichment failure.
|
|
80
|
+
}
|
|
81
|
+
return span;
|
|
82
|
+
};
|
|
83
|
+
this._patched.push([instrumentation, '_startSpan', original]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
uninstrument() {
|
|
87
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
88
|
+
owner[methodName] = original;
|
|
89
|
+
}
|
|
90
|
+
this._patched = [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { BedrockInputMessageContentInstrumentor };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streamed-output message content for the Bedrock invoke_model stream span.
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-bedrock` accumulates the streamed text but formats
|
|
7
|
+
* the final `gen_ai.output.messages` by passing that string through its chat
|
|
8
|
+
* block mapper, which only accepts block arrays — so streamed Nova spans end up
|
|
9
|
+
* with an assistant message whose `parts` is empty. This complement wraps the
|
|
10
|
+
* instrumentor's per-chunk `_handleNovaStreamingMetadata(span, parsedResponse)`
|
|
11
|
+
* hook: it accumulates the `contentBlockDelta` text and the `messageStop` stop
|
|
12
|
+
* reason itself, and re-stamps a correct output message when the trailing
|
|
13
|
+
* `metadata` chunk (always last in a Nova stream) arrives — after the
|
|
14
|
+
* instrumentor has written its empty-parts message. The span processor strips
|
|
15
|
+
* the attribute when message-content capture is disabled.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Nova stop reasons mapped to the OTel finish_reason values the instrumentor
|
|
19
|
+
// uses on non-streamed spans, so streamed and non-streamed rows normalize alike.
|
|
20
|
+
const FINISH_REASON_BY_STOP_REASON = {
|
|
21
|
+
end_turn: 'stop',
|
|
22
|
+
stop_sequence: 'stop',
|
|
23
|
+
max_tokens: 'length',
|
|
24
|
+
tool_use: 'tool_call',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class BedrockStreamingOutputMessageInstrumentor {
|
|
28
|
+
constructor() {
|
|
29
|
+
// [instrumentation, methodName, originalFunction] for uninstrument().
|
|
30
|
+
this._patched = [];
|
|
31
|
+
// span -> {text, stopReason} accumulated across the stream's chunks.
|
|
32
|
+
this._streamStateBySpan = new WeakMap();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// No-op (and never throws) if the instrumentor's internals have changed shape —
|
|
36
|
+
// the worst case is an empty streamed output message, never a crash.
|
|
37
|
+
instrument(instrumentation) {
|
|
38
|
+
if (!instrumentation) return;
|
|
39
|
+
const original = instrumentation._handleNovaStreamingMetadata;
|
|
40
|
+
if (typeof original !== 'function') return;
|
|
41
|
+
|
|
42
|
+
const streamStateBySpan = this._streamStateBySpan;
|
|
43
|
+
instrumentation._handleNovaStreamingMetadata = function handleWithOutputMessage(
|
|
44
|
+
span,
|
|
45
|
+
parsedResponse
|
|
46
|
+
) {
|
|
47
|
+
try {
|
|
48
|
+
const state = streamStateBySpan.get(span) || { text: '', stopReason: null };
|
|
49
|
+
const delta =
|
|
50
|
+
parsedResponse &&
|
|
51
|
+
parsedResponse.contentBlockDelta &&
|
|
52
|
+
parsedResponse.contentBlockDelta.delta &&
|
|
53
|
+
parsedResponse.contentBlockDelta.delta.text;
|
|
54
|
+
if (typeof delta === 'string') state.text += delta;
|
|
55
|
+
const stopReason =
|
|
56
|
+
parsedResponse && parsedResponse.messageStop && parsedResponse.messageStop.stopReason;
|
|
57
|
+
if (stopReason != null) state.stopReason = stopReason;
|
|
58
|
+
streamStateBySpan.set(span, state);
|
|
59
|
+
|
|
60
|
+
if (parsedResponse && parsedResponse.metadata && state.text) {
|
|
61
|
+
const message = {
|
|
62
|
+
role: 'assistant',
|
|
63
|
+
parts: [{ type: 'text', content: state.text }],
|
|
64
|
+
};
|
|
65
|
+
if (state.stopReason != null) {
|
|
66
|
+
message.finish_reason =
|
|
67
|
+
FINISH_REASON_BY_STOP_REASON[state.stopReason] || state.stopReason;
|
|
68
|
+
}
|
|
69
|
+
span.setAttribute('gen_ai.output.messages', JSON.stringify([message]));
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Never break the caller's stream over an enrichment failure.
|
|
73
|
+
}
|
|
74
|
+
return original.call(this, span, parsedResponse);
|
|
75
|
+
};
|
|
76
|
+
this._patched.push([instrumentation, '_handleNovaStreamingMetadata', original]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
uninstrument() {
|
|
80
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
81
|
+
owner[methodName] = original;
|
|
82
|
+
}
|
|
83
|
+
this._patched = [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { BedrockStreamingOutputMessageInstrumentor };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bedrock telemetry complement — the registered orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* Wires the weflayr-supplied patches the traceloop Bedrock instrumentor misses:
|
|
7
|
+
* cached-prompt token counts on the invoke_model span (Nova returns them in the
|
|
8
|
+
* response body but traceloop never reads them) and the streamed output message
|
|
9
|
+
* content (its stream path formats an empty-parts message).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { BedrockPromptCachingInstrumentor } = require('./get_cache_telemetry');
|
|
13
|
+
const { BedrockInputMessageContentInstrumentor } = require('./get_input_message_content_telemetry');
|
|
14
|
+
const {
|
|
15
|
+
BedrockStreamingOutputMessageInstrumentor,
|
|
16
|
+
} = require('./get_streaming_output_message_telemetry');
|
|
17
|
+
|
|
18
|
+
class BedrockTelemetryInstrumentor {
|
|
19
|
+
constructor() {
|
|
20
|
+
this._subInstrumentors = [
|
|
21
|
+
new BedrockPromptCachingInstrumentor(),
|
|
22
|
+
new BedrockInputMessageContentInstrumentor(),
|
|
23
|
+
new BedrockStreamingOutputMessageInstrumentor(),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
instrument(instrumentation) {
|
|
28
|
+
for (const sub of this._subInstrumentors) sub.instrument(instrumentation);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
uninstrument() {
|
|
32
|
+
for (const sub of this._subInstrumentors) sub.uninstrument();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { BedrockTelemetryInstrumentor };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chat token-usage enrichment complement for the Cohere v1 chat path.
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-cohere` creates the `cohere.chat` span but reads
|
|
7
|
+
* token usage from the obsolete `result.token_count.{prompt,response}_tokens`
|
|
8
|
+
* shape; modern cohere-ai returns usage under `meta.billedUnits` instead, so the
|
|
9
|
+
* traceloop span ends up with zero chat tokens. This thin complement backfills
|
|
10
|
+
* `gen_ai.usage.{input,output}_tokens` from `meta.billedUnits`, the same source
|
|
11
|
+
* traceloop already uses for the `generate` endpoint — bringing Node chat tokens
|
|
12
|
+
* to parity with the Python SDK (OpenLLMetry captures them natively, so there is
|
|
13
|
+
* no Python counterpart).
|
|
14
|
+
*
|
|
15
|
+
* It hooks the internal `CohereClient.prototype.__chat` (which the public `chat`
|
|
16
|
+
* delegates to synchronously) rather than the public `chat`: traceloop wraps
|
|
17
|
+
* `chat` and opens the span around it, so `__chat` runs while that span is the
|
|
18
|
+
* active span — letting this complement enrich it regardless of patch order.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { trace } = require('@opentelemetry/api');
|
|
22
|
+
|
|
23
|
+
function _billedUnits(result) {
|
|
24
|
+
// Public `chat` resolves to the response body, but `__chat` resolves to the
|
|
25
|
+
// Fern wrapper `{ data, rawResponse }`; read billedUnits off either shape.
|
|
26
|
+
const response = result && result.data ? result.data : result;
|
|
27
|
+
return response && response.meta ? response.meta.billedUnits : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _setChatTokenUsage(span, result) {
|
|
31
|
+
const billedUnits = _billedUnits(result);
|
|
32
|
+
if (!billedUnits) return;
|
|
33
|
+
if (typeof billedUnits.inputTokens === 'number') {
|
|
34
|
+
span.setAttribute('gen_ai.usage.input_tokens', billedUnits.inputTokens);
|
|
35
|
+
}
|
|
36
|
+
if (typeof billedUnits.outputTokens === 'number') {
|
|
37
|
+
span.setAttribute('gen_ai.usage.output_tokens', billedUnits.outputTokens);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class CohereChatTokenUsageInstrumentor {
|
|
42
|
+
constructor() {
|
|
43
|
+
// [ownerPrototype, methodName, originalFunction] for uninstrument().
|
|
44
|
+
this._patched = [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
instrument() {
|
|
48
|
+
let CohereClient;
|
|
49
|
+
try {
|
|
50
|
+
({ CohereClient } = require('cohere-ai/Client'));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'weflayr.autoInstrument(COHERE): the cohere-ai client is not installed. ' +
|
|
54
|
+
'Install it with: npm install cohere-ai\n' +
|
|
55
|
+
`Underlying error: ${err.message}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this._wrap(CohereClient.prototype, '__chat');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
uninstrument() {
|
|
63
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
64
|
+
owner[methodName] = original;
|
|
65
|
+
}
|
|
66
|
+
this._patched = [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_wrap(owner, methodName) {
|
|
70
|
+
const original = owner[methodName];
|
|
71
|
+
|
|
72
|
+
function wrapper(...args) {
|
|
73
|
+
// Captured synchronously, while traceloop's chat span is still active.
|
|
74
|
+
const span = trace.getActiveSpan();
|
|
75
|
+
const result = original.apply(this, args);
|
|
76
|
+
if (!span || !span.isRecording || !span.isRecording()) return result;
|
|
77
|
+
return Promise.resolve(result).then((response) => {
|
|
78
|
+
_setChatTokenUsage(span, response);
|
|
79
|
+
return response;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
owner[methodName] = wrapper;
|
|
84
|
+
this._patched.push([owner, methodName, original]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { CohereChatTokenUsageInstrumentor };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hand-written OTel instrumentor for the Cohere embeddings endpoint.
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-cohere` (the package weflayr uses for Cohere on
|
|
7
|
+
* Node) wraps chat/generate/rerank but not `embed`, so this ships inside weflayr
|
|
8
|
+
* to cover it. OpenLLMetry's cohere instrumentor doesn't cover `embed` either, so
|
|
9
|
+
* the Python SDK has a mirror complement (`cohere_embeddings.py`) shaped to
|
|
10
|
+
* normalize identically. It is registered alongside the traceloop instrumentor
|
|
11
|
+
* whenever `autoInstrument({ aiSdks: 'cohere' })` runs.
|
|
12
|
+
*
|
|
13
|
+
* Embeddings are billed per input token, so the span records the input tokens
|
|
14
|
+
* Cohere returns under `gen_ai.usage.input_tokens`; there is no completion.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { trace, SpanStatusCode } = require('@opentelemetry/api');
|
|
18
|
+
|
|
19
|
+
const TRACER_NAME = 'weflayr.instrumentation.cohere_embeddings';
|
|
20
|
+
// Matches the gen_ai.system the cohere chat instrumentors set (Python + Node).
|
|
21
|
+
const SYSTEM = 'Cohere';
|
|
22
|
+
const EMBEDDINGS_OPERATION = 'embeddings';
|
|
23
|
+
|
|
24
|
+
function _recordError(span, error) {
|
|
25
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
|
|
26
|
+
span.recordException(error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _wrapEmbed(owner, patched) {
|
|
30
|
+
const original = owner.embed;
|
|
31
|
+
|
|
32
|
+
function wrapper(request, ...rest) {
|
|
33
|
+
const span = trace.getTracer(TRACER_NAME).startSpan(EMBEDDINGS_OPERATION);
|
|
34
|
+
span.setAttribute('gen_ai.system', SYSTEM);
|
|
35
|
+
span.setAttribute('gen_ai.operation.name', EMBEDDINGS_OPERATION);
|
|
36
|
+
if (request && request.model) {
|
|
37
|
+
span.setAttribute('gen_ai.request.model', request.model);
|
|
38
|
+
}
|
|
39
|
+
// The embedded texts are the prompt; stamp one user message per text, like
|
|
40
|
+
// the chat path. The span processor strips it when content capture is off.
|
|
41
|
+
if (request && Array.isArray(request.texts) && request.texts.length) {
|
|
42
|
+
span.setAttribute(
|
|
43
|
+
'gen_ai.input.messages',
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
request.texts.map((text) => ({ role: 'user', parts: [{ type: 'text', content: text }] }))
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return Promise.resolve(original.call(this, request, ...rest)).then(
|
|
50
|
+
(response) => {
|
|
51
|
+
// Node SDK reports the billed input tokens under camelCase keys.
|
|
52
|
+
const inputTokens =
|
|
53
|
+
response?.meta?.billedUnits?.inputTokens ?? response?.meta?.tokens?.inputTokens;
|
|
54
|
+
if (inputTokens != null) {
|
|
55
|
+
span.setAttribute('gen_ai.usage.input_tokens', inputTokens);
|
|
56
|
+
}
|
|
57
|
+
span.end();
|
|
58
|
+
return response;
|
|
59
|
+
},
|
|
60
|
+
(error) => {
|
|
61
|
+
_recordError(span, error);
|
|
62
|
+
span.end();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
owner.embed = wrapper;
|
|
69
|
+
patched.push([owner, 'embed', original]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class CohereEmbeddingsInstrumentor {
|
|
73
|
+
constructor() {
|
|
74
|
+
// [ownerPrototype, methodName, originalFunction] for uninstrument().
|
|
75
|
+
this._patched = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
instrument() {
|
|
79
|
+
let CohereClient;
|
|
80
|
+
let V2Client;
|
|
81
|
+
try {
|
|
82
|
+
// v1 `embed` is a prototype method on CohereClient; v2 `embed` lives on
|
|
83
|
+
// V2Client.prototype (CohereClientV2 binds it as an instance property, so
|
|
84
|
+
// it must be patched on the underlying V2Client class instead).
|
|
85
|
+
({ CohereClient } = require('cohere-ai/Client'));
|
|
86
|
+
({ V2Client } = require('cohere-ai/api/resources/v2/client/Client'));
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'weflayr.autoInstrument(COHERE): the cohere-ai client is not installed. ' +
|
|
90
|
+
'Install it with: npm install cohere-ai\n' +
|
|
91
|
+
`Underlying error: ${err.message}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_wrapEmbed(CohereClient.prototype, this._patched);
|
|
96
|
+
_wrapEmbed(V2Client.prototype, this._patched);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
uninstrument() {
|
|
100
|
+
for (const [owner, methodName, original] of this._patched) {
|
|
101
|
+
owner[methodName] = original;
|
|
102
|
+
}
|
|
103
|
+
this._patched = [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { CohereEmbeddingsInstrumentor };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cohere telemetry complement — the registered orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* Wires the weflayr-supplied patches the traceloop Cohere instrumentor misses:
|
|
7
|
+
* - chat token usage (traceloop reads the obsolete usage shape and records
|
|
8
|
+
* zero chat tokens), and
|
|
9
|
+
* - the embeddings endpoint it doesn't cover.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { CohereChatTokenUsageInstrumentor } = require('./get_chat_telemetry');
|
|
13
|
+
const { CohereEmbeddingsInstrumentor } = require('./get_embeddings_telemetry');
|
|
14
|
+
|
|
15
|
+
class CohereTelemetryInstrumentor {
|
|
16
|
+
constructor() {
|
|
17
|
+
this._subInstrumentors = [
|
|
18
|
+
new CohereChatTokenUsageInstrumentor(),
|
|
19
|
+
new CohereEmbeddingsInstrumentor(),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
instrument(instrumentation) {
|
|
24
|
+
for (const sub of this._subInstrumentors) sub.instrument(instrumentation);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
uninstrument() {
|
|
28
|
+
for (const sub of this._subInstrumentors) sub.uninstrument();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { CohereTelemetryInstrumentor };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-modality audio token enrichment for the Google GenAI (Gemini) chat span.
|
|
5
|
+
*
|
|
6
|
+
* Neither instrumentor breaks usage down by modality, so for audio in/out (e.g.
|
|
7
|
+
* Gemini TTS) we read the response's per-modality token details and set
|
|
8
|
+
* `gen_ai.usage.{input,output}_tokens_details.audio_tokens`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Sum the token counts whose modality is AUDIO across a *TokensDetails array.
|
|
12
|
+
function _audioTokens(details) {
|
|
13
|
+
if (!Array.isArray(details)) return 0;
|
|
14
|
+
return details.reduce(
|
|
15
|
+
(total, detail) => total + (detail && detail.modality === 'AUDIO' ? detail.tokenCount || 0 : 0),
|
|
16
|
+
0
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setAudioTelemetry(span, response) {
|
|
21
|
+
const usage = response && response.usageMetadata;
|
|
22
|
+
if (!usage) return;
|
|
23
|
+
const inputAudio = _audioTokens(usage.promptTokensDetails);
|
|
24
|
+
const outputAudio = _audioTokens(usage.candidatesTokensDetails);
|
|
25
|
+
if (inputAudio) span.setAttribute('gen_ai.usage.input_tokens_details.audio_tokens', inputAudio);
|
|
26
|
+
if (outputAudio)
|
|
27
|
+
span.setAttribute('gen_ai.usage.output_tokens_details.audio_tokens', outputAudio);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { setAudioTelemetry, _audioTokens };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context-caching token enrichment for the Google GenAI (Gemini) chat span.
|
|
5
|
+
*
|
|
6
|
+
* Neither instrumentor records cached-content tokens, so we read
|
|
7
|
+
* `usageMetadata.cachedContentTokenCount` (explicit or implicit caching) and
|
|
8
|
+
* split it by modality: audio cached tokens go to
|
|
9
|
+
* `gen_ai.usage.input_tokens_details.cached_audio_tokens`and the remainder to `gen_ai.usage.cache_read_input_tokens`
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { _audioTokens } = require('./get_audio_telemetry');
|
|
13
|
+
|
|
14
|
+
function setCacheTelemetry(span, response) {
|
|
15
|
+
const usage = response && response.usageMetadata;
|
|
16
|
+
const cachedTokens = (usage && usage.cachedContentTokenCount) || 0;
|
|
17
|
+
if (!cachedTokens) return;
|
|
18
|
+
const cachedAudio = _audioTokens(usage.cacheTokensDetails);
|
|
19
|
+
if (cachedAudio)
|
|
20
|
+
span.setAttribute('gen_ai.usage.input_tokens_details.cached_audio_tokens', cachedAudio);
|
|
21
|
+
const cacheRead = Math.max(0, cachedTokens - cachedAudio);
|
|
22
|
+
if (cacheRead) span.setAttribute('gen_ai.usage.cache_read_input_tokens', cacheRead);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { setCacheTelemetry };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Embeddings-endpoint telemetry for Google GenAI (Gemini).
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-google-generativeai` wraps generate_content but not
|
|
7
|
+
* embeddings, so this covers it by starting its own span on the private
|
|
8
|
+
* `Models.prototype.embedContentInternal` (which the public `embedContent`
|
|
9
|
+
* delegates to). The Gemini Developer API returns NO token usage on embedContent
|
|
10
|
+
* (only Vertex does), so the span records the billable unit weflayr can price:
|
|
11
|
+
* the input character count under `gen_ai.usage.input_characters` (normalized to
|
|
12
|
+
* num_input_chars).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { trace, SpanStatusCode } = require('@opentelemetry/api');
|
|
16
|
+
|
|
17
|
+
const { getProviderName } = require('./get_provider_name');
|
|
18
|
+
|
|
19
|
+
const TRACER_NAME = 'weflayr.instrumentation.google.embeddings';
|
|
20
|
+
const EMBEDDINGS_OPERATION = 'embeddings';
|
|
21
|
+
|
|
22
|
+
// `contents` is a string, a Content/Part object, or an array of those; the
|
|
23
|
+
// billable unit is the total number of characters of text across them.
|
|
24
|
+
function _contentCharacters(contents) {
|
|
25
|
+
if (contents == null) return 0;
|
|
26
|
+
if (typeof contents === 'string') return contents.length;
|
|
27
|
+
if (Array.isArray(contents)) {
|
|
28
|
+
return contents.reduce((total, item) => total + _contentCharacters(item), 0);
|
|
29
|
+
}
|
|
30
|
+
if (typeof contents === 'object') {
|
|
31
|
+
if (typeof contents.text === 'string') return contents.text.length;
|
|
32
|
+
if (Array.isArray(contents.parts)) return _contentCharacters(contents.parts);
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The embedded text mapped to the role+parts message shape the chat path uses:
|
|
38
|
+
// a string (or {text} / {parts} object) becomes one user message per content.
|
|
39
|
+
function _contentToMessage(content) {
|
|
40
|
+
if (typeof content === 'string') {
|
|
41
|
+
return { role: 'user', parts: [{ type: 'text', content }] };
|
|
42
|
+
}
|
|
43
|
+
if (content && typeof content.text === 'string') {
|
|
44
|
+
return { role: 'user', parts: [{ type: 'text', content: content.text }] };
|
|
45
|
+
}
|
|
46
|
+
return { role: 'user', parts: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _recordError(span, error) {
|
|
50
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
|
|
51
|
+
span.recordException(error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Wrap `Models.prototype.embedContentInternal`, pushing the restore tuple onto
|
|
55
|
+
// the shared `patched` array the orchestrator owns for uninstrument().
|
|
56
|
+
function instrumentEmbeddings(Models, patched) {
|
|
57
|
+
const owner = Models.prototype;
|
|
58
|
+
const original = owner.embedContentInternal;
|
|
59
|
+
if (typeof original !== 'function') return;
|
|
60
|
+
|
|
61
|
+
function wrapper(params, ...rest) {
|
|
62
|
+
const span = trace.getTracer(TRACER_NAME).startSpan(EMBEDDINGS_OPERATION);
|
|
63
|
+
span.setAttribute('gen_ai.system', getProviderName(this));
|
|
64
|
+
span.setAttribute('gen_ai.operation.name', EMBEDDINGS_OPERATION);
|
|
65
|
+
if (params && params.model) {
|
|
66
|
+
// The chat path strips the leading "models/" prefix; mirror it.
|
|
67
|
+
span.setAttribute('gen_ai.request.model', params.model.replace(/^models\//, ''));
|
|
68
|
+
}
|
|
69
|
+
span.setAttribute(
|
|
70
|
+
'gen_ai.usage.input_characters',
|
|
71
|
+
_contentCharacters(params && params.contents)
|
|
72
|
+
);
|
|
73
|
+
// The embedded text is the prompt; stamp it like the chat path does. The
|
|
74
|
+
// span processor strips it when message-content capture is disabled.
|
|
75
|
+
if (params && params.contents != null) {
|
|
76
|
+
const contentsList = Array.isArray(params.contents) ? params.contents : [params.contents];
|
|
77
|
+
span.setAttribute(
|
|
78
|
+
'gen_ai.input.messages',
|
|
79
|
+
JSON.stringify(contentsList.map(_contentToMessage))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return Promise.resolve(original.call(this, params, ...rest)).then(
|
|
83
|
+
(response) => {
|
|
84
|
+
span.end();
|
|
85
|
+
return response;
|
|
86
|
+
},
|
|
87
|
+
(error) => {
|
|
88
|
+
_recordError(span, error);
|
|
89
|
+
span.end();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
owner.embedContentInternal = wrapper;
|
|
96
|
+
patched.push([owner, 'embedContentInternal', original]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { instrumentEmbeddings };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider-name enrichment for the Google GenAI (Gemini) chat span.
|
|
5
|
+
*
|
|
6
|
+
* `@traceloop/instrumentation-google-generativeai` hardcodes
|
|
7
|
+
* `gen_ai.provider.name = "gcp.gen_ai"` and can't tell Vertex from the Gemini
|
|
8
|
+
* Developer API. We read the client's Vertex flag and return the value to set as
|
|
9
|
+
* `gen_ai.system` (which the normalizer prefers): `vertex_ai` / `gemini`,
|
|
10
|
+
* matching what the Python google-genai instrumentor reports natively. Node-only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Read the Vertex-vs-Dev backend off the Models instance's api client.
|
|
14
|
+
function getProviderName(instance) {
|
|
15
|
+
try {
|
|
16
|
+
if (instance && instance.apiClient && typeof instance.apiClient.isVertexAI === 'function') {
|
|
17
|
+
return instance.apiClient.isVertexAI() ? 'vertex_ai' : 'gemini';
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// fall through to default
|
|
21
|
+
}
|
|
22
|
+
return 'gemini';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { getProviderName };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reasoning (thinking) token enrichment for the Google GenAI (Gemini) chat span.
|
|
5
|
+
*
|
|
6
|
+
* Neither instrumentor surfaces the thinking budget. Gemini reports thinking
|
|
7
|
+
* under `usageMetadata.thoughtsTokenCount`, which is SEPARATE from
|
|
8
|
+
* `candidatesTokenCount` (the visible output) and excluded from the totals, so
|
|
9
|
+
* without this the thinking tokens are lost. Stamp them as
|
|
10
|
+
* `gen_ai.usage.thoughts_token_count` so the normalizer can fold them into the
|
|
11
|
+
* completion count.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function setReasoningTelemetry(span, response) {
|
|
15
|
+
const usage = response && response.usageMetadata;
|
|
16
|
+
if (!usage) return;
|
|
17
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
18
|
+
if (thoughts) span.setAttribute('gen_ai.usage.thoughts_token_count', thoughts);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { setReasoningTelemetry };
|