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,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 };