weflayr 0.21.0 → 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 (28) hide show
  1. package/index.d.ts +1 -1
  2. package/package.json +1 -1
  3. package/src/instrumentation/anthropic/get_streaming_stop_reason_telemetry.js +121 -0
  4. package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
  5. package/src/instrumentation/anthropic/set_telemetry_attributes.js +13 -3
  6. package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
  7. package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
  8. package/src/instrumentation/bedrock/set_telemetry_attributes.js +12 -3
  9. package/src/instrumentation/cohere/get_embeddings_telemetry.js +10 -0
  10. package/src/instrumentation/google/get_audio_telemetry.js +1 -2
  11. package/src/instrumentation/google/get_cache_telemetry.js +1 -3
  12. package/src/instrumentation/google/get_embeddings_telemetry.js +22 -1
  13. package/src/instrumentation/openai/get_embeddings_telemetry.js +12 -0
  14. package/src/otel/exporter.js +1 -6
  15. package/src/otel/span-processor.js +30 -1
  16. package/src/setup.js +1 -2
  17. package/tests/index.test.js +102 -0
  18. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +33 -0
  19. package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
  20. package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
  21. package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
  22. package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
  23. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +3 -0
  24. package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +18 -4
  25. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +3 -0
  26. package/tests/instrumentation/google/set_telemetry_attributes.test.js +33 -1
  27. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +19 -0
  28. package/tests/instrumentation/openai/get_streaming_usage.test.js +29 -0
package/index.d.ts CHANGED
@@ -62,7 +62,7 @@ export const AIProviderName: {
62
62
  export type AIProviderName = (typeof AIProviderName)[keyof typeof AIProviderName];
63
63
 
64
64
  export interface SetupOptions {
65
- /** Weflayr API key. The server resolves your client_id from this token. */
65
+ /** Weflayr API key. The server resolves your project_uuid from this token. */
66
66
  apiKey: string;
67
67
  /** Override the intake base URL. Defaults to WEFLAYR_BASE_URL or https://api.weflayr.com. */
68
68
  baseUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weflayr",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
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",
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Streamed stop-reason enrichment for the Anthropic chat span.
5
+ *
6
+ * `@traceloop/instrumentation-anthropic` accumulates a streamed response into a
7
+ * result object but never reads the `message_delta` chunk's `delta.stop_reason`,
8
+ * so streamed spans format their output message with an empty `finish_reason`.
9
+ *
10
+ * `_streamingWrapPromise(client, moduleExports, { span, type, promise })`
11
+ * returns `new moduleExports.APIPromise(...)` whose parse step wraps the real
12
+ * SDK stream with the instrumentor's accumulating iterator. This complement
13
+ * hands it a patched `moduleExports` whose APIPromise interposes on that parse
14
+ * step, observing the chunks (outside the accumulating iterator, so every chunk
15
+ * is seen before the iterator finishes) to capture the stop reason — which is
16
+ * then injected into the accumulated result just before `_endSpan` formats the
17
+ * output message.
18
+ */
19
+
20
+ class AnthropicStreamingStopReasonInstrumentor {
21
+ constructor() {
22
+ // [instrumentation, methodName, originalFunction] for uninstrument().
23
+ this._patched = [];
24
+ // span -> stop_reason captured from the stream's message_delta chunk.
25
+ this._stopReasonBySpan = new WeakMap();
26
+ }
27
+
28
+ // No-op (and never throws) if the instrumentor's internals have changed shape —
29
+ // the worst case is an empty streamed finish_reason, never a crash.
30
+ instrument(instrumentation) {
31
+ if (!instrumentation) return;
32
+
33
+ const stopReasonBySpan = this._stopReasonBySpan;
34
+
35
+ async function* observeStopReason(stream, span) {
36
+ for await (const chunk of stream) {
37
+ try {
38
+ if (
39
+ chunk &&
40
+ chunk.type === 'message_delta' &&
41
+ chunk.delta &&
42
+ chunk.delta.stop_reason != null
43
+ ) {
44
+ stopReasonBySpan.set(span, chunk.delta.stop_reason);
45
+ }
46
+ } catch {
47
+ // Never break the caller's stream over an enrichment failure.
48
+ }
49
+ yield chunk;
50
+ }
51
+ }
52
+
53
+ const streamOriginal = instrumentation._streamingWrapPromise;
54
+ if (typeof streamOriginal === 'function') {
55
+ instrumentation._streamingWrapPromise = function streamWithStopReason(
56
+ client,
57
+ moduleExports,
58
+ opts
59
+ ) {
60
+ const span = opts && opts.span;
61
+ if (!span || !moduleExports || typeof moduleExports.APIPromise !== 'function') {
62
+ return streamOriginal.call(this, client, moduleExports, opts);
63
+ }
64
+ class APIPromiseWithStopReason extends moduleExports.APIPromise {
65
+ constructor(promiseClient, responsePromise, parseResponse) {
66
+ super(promiseClient, responsePromise, async (parseClient, props) => {
67
+ const stream = await parseResponse(parseClient, props);
68
+ try {
69
+ if (
70
+ stream &&
71
+ typeof stream[Symbol.asyncIterator] === 'function' &&
72
+ typeof stream.constructor === 'function'
73
+ ) {
74
+ return new stream.constructor(
75
+ () => observeStopReason(stream, span),
76
+ stream.controller
77
+ );
78
+ }
79
+ } catch {
80
+ // Fall through to the unobserved stream.
81
+ }
82
+ return stream;
83
+ });
84
+ }
85
+ }
86
+ // The SDK's module exports are non-writable, so the override is an own
87
+ // defined property shadowing the inherited one.
88
+ const patchedModuleExports = Object.create(moduleExports, {
89
+ APIPromise: { value: APIPromiseWithStopReason, configurable: true },
90
+ });
91
+ return streamOriginal.call(this, client, patchedModuleExports, opts);
92
+ };
93
+ this._patched.push([instrumentation, '_streamingWrapPromise', streamOriginal]);
94
+ }
95
+
96
+ const endSpanOriginal = instrumentation._endSpan;
97
+ if (typeof endSpanOriginal === 'function') {
98
+ instrumentation._endSpan = function endSpanWithStopReason(args) {
99
+ try {
100
+ const captured = args && args.span && stopReasonBySpan.get(args.span);
101
+ if (captured != null && args.result && args.result.stop_reason == null) {
102
+ args.result.stop_reason = captured;
103
+ }
104
+ } catch {
105
+ // Never break span finalization over an enrichment failure.
106
+ }
107
+ return endSpanOriginal.call(this, args);
108
+ };
109
+ this._patched.push([instrumentation, '_endSpan', endSpanOriginal]);
110
+ }
111
+ }
112
+
113
+ uninstrument() {
114
+ for (const [owner, methodName, original] of this._patched) {
115
+ owner[methodName] = original;
116
+ }
117
+ this._patched = [];
118
+ }
119
+ }
120
+
121
+ module.exports = { AnthropicStreamingStopReasonInstrumentor };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tool-definitions enrichment for the Anthropic chat span.
5
+ *
6
+ * `@traceloop/instrumentation-anthropic` records the request model/temperature
7
+ * attributes in `startSpan({ type, params })` but never the request's `tools`,
8
+ * so tool-using calls carry no `gen_ai.tool.definitions`. Wrap `startSpan` to
9
+ * stamp them in the same `{name, description, input_schema}` shape the request
10
+ * uses. The span processor strips the attribute when message-content capture is
11
+ * disabled.
12
+ */
13
+
14
+ class AnthropicToolDefinitionsInstrumentor {
15
+ constructor() {
16
+ // [instrumentation, methodName, originalFunction] for uninstrument().
17
+ this._patched = [];
18
+ }
19
+
20
+ // No-op (and never throws) if the instrumentor's internals have changed shape —
21
+ // the worst case is missing tool definitions, never a crash.
22
+ instrument(instrumentation) {
23
+ if (!instrumentation) return;
24
+ const startSpanOriginal = instrumentation.startSpan;
25
+ if (typeof startSpanOriginal !== 'function') return;
26
+
27
+ instrumentation.startSpan = function startSpanWithToolDefinitions(request) {
28
+ const span = startSpanOriginal.call(this, request);
29
+ try {
30
+ const tools = request && request.params && request.params.tools;
31
+ if (Array.isArray(tools) && tools.length) {
32
+ span.setAttribute('gen_ai.tool.definitions', JSON.stringify(tools));
33
+ }
34
+ } catch {
35
+ // Never break span creation over an enrichment failure.
36
+ }
37
+ return span;
38
+ };
39
+ this._patched.push([instrumentation, 'startSpan', startSpanOriginal]);
40
+ }
41
+
42
+ uninstrument() {
43
+ for (const [owner, methodName, original] of this._patched) {
44
+ owner[methodName] = original;
45
+ }
46
+ this._patched = [];
47
+ }
48
+ }
49
+
50
+ module.exports = { AnthropicToolDefinitionsInstrumentor };
@@ -3,16 +3,26 @@
3
3
  /**
4
4
  * Anthropic telemetry complement — the registered orchestrator.
5
5
  *
6
- * Wires the one weflayr-supplied patch the traceloop Anthropic instrumentor
6
+ * Wires the weflayr-supplied patches the traceloop Anthropic instrumentor
7
7
  * misses: cached-prompt token counts on the chat span (it reads them off the
8
- * response but writes no `gen_ai.usage.cache_*` attribute).
8
+ * response but writes no `gen_ai.usage.cache_*` attribute), the request's tool
9
+ * definitions, and the streamed stop reason (its stream accumulator drops it,
10
+ * leaving an empty finish_reason on the output message).
9
11
  */
10
12
 
11
13
  const { AnthropicPromptCachingInstrumentor } = require('./get_cache_and_reasoning_telemetry');
14
+ const {
15
+ AnthropicStreamingStopReasonInstrumentor,
16
+ } = require('./get_streaming_stop_reason_telemetry');
17
+ const { AnthropicToolDefinitionsInstrumentor } = require('./get_tool_definitions_telemetry');
12
18
 
13
19
  class AnthropicTelemetryInstrumentor {
14
20
  constructor() {
15
- this._subInstrumentors = [new AnthropicPromptCachingInstrumentor()];
21
+ this._subInstrumentors = [
22
+ new AnthropicPromptCachingInstrumentor(),
23
+ new AnthropicStreamingStopReasonInstrumentor(),
24
+ new AnthropicToolDefinitionsInstrumentor(),
25
+ ];
16
26
  }
17
27
 
18
28
  instrument(instrumentation) {
@@ -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 };
@@ -3,16 +3,25 @@
3
3
  /**
4
4
  * Bedrock telemetry complement — the registered orchestrator.
5
5
  *
6
- * Wires the one weflayr-supplied patch the traceloop Bedrock instrumentor misses:
6
+ * Wires the weflayr-supplied patches the traceloop Bedrock instrumentor misses:
7
7
  * cached-prompt token counts on the invoke_model span (Nova returns them in the
8
- * response body but traceloop never reads them).
8
+ * response body but traceloop never reads them) and the streamed output message
9
+ * content (its stream path formats an empty-parts message).
9
10
  */
10
11
 
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');
12
17
 
13
18
  class BedrockTelemetryInstrumentor {
14
19
  constructor() {
15
- this._subInstrumentors = [new BedrockPromptCachingInstrumentor()];
20
+ this._subInstrumentors = [
21
+ new BedrockPromptCachingInstrumentor(),
22
+ new BedrockInputMessageContentInstrumentor(),
23
+ new BedrockStreamingOutputMessageInstrumentor(),
24
+ ];
16
25
  }
17
26
 
18
27
  instrument(instrumentation) {
@@ -36,6 +36,16 @@ function _wrapEmbed(owner, patched) {
36
36
  if (request && request.model) {
37
37
  span.setAttribute('gen_ai.request.model', request.model);
38
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
+ }
39
49
  return Promise.resolve(original.call(this, request, ...rest)).then(
40
50
  (response) => {
41
51
  // Node SDK reports the billed input tokens under camelCase keys.
@@ -5,8 +5,7 @@
5
5
  *
6
6
  * Neither instrumentor breaks usage down by modality, so for audio in/out (e.g.
7
7
  * Gemini TTS) we read the response's per-modality token details and set
8
- * `gen_ai.usage.{input,output}_tokens_details.audio_tokens` (normalized to
9
- * token_audio_input / token_audio_output).
8
+ * `gen_ai.usage.{input,output}_tokens_details.audio_tokens`.
10
9
  */
11
10
 
12
11
  // Sum the token counts whose modality is AUDIO across a *TokensDetails array.
@@ -6,9 +6,7 @@
6
6
  * Neither instrumentor records cached-content tokens, so we read
7
7
  * `usageMetadata.cachedContentTokenCount` (explicit or implicit caching) and
8
8
  * split it by modality: audio cached tokens go to
9
- * `gen_ai.usage.input_tokens_details.cached_audio_tokens` (normalized to
10
- * token_audio_cached) and the remainder to `gen_ai.usage.cache_read_input_tokens`
11
- * (normalized to token_text_cached).
9
+ * `gen_ai.usage.input_tokens_details.cached_audio_tokens`and the remainder to `gen_ai.usage.cache_read_input_tokens`
12
10
  */
13
11
 
14
12
  const { _audioTokens } = require('./get_audio_telemetry');
@@ -9,7 +9,7 @@
9
9
  * delegates to). The Gemini Developer API returns NO token usage on embedContent
10
10
  * (only Vertex does), so the span records the billable unit weflayr can price:
11
11
  * the input character count under `gen_ai.usage.input_characters` (normalized to
12
- * count_char).
12
+ * num_input_chars).
13
13
  */
14
14
 
15
15
  const { trace, SpanStatusCode } = require('@opentelemetry/api');
@@ -34,6 +34,18 @@ function _contentCharacters(contents) {
34
34
  return 0;
35
35
  }
36
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
+
37
49
  function _recordError(span, error) {
38
50
  span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? String(error) });
39
51
  span.recordException(error);
@@ -58,6 +70,15 @@ function instrumentEmbeddings(Models, patched) {
58
70
  'gen_ai.usage.input_characters',
59
71
  _contentCharacters(params && params.contents)
60
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
+ }
61
82
  return Promise.resolve(original.call(this, params, ...rest)).then(
62
83
  (response) => {
63
84
  span.end();
@@ -60,6 +60,18 @@ class OpenAIEmbeddingsInstrumentor {
60
60
  if (body && body.model) {
61
61
  span.setAttribute('gen_ai.request.model', body.model);
62
62
  }
63
+ // One user message per input element, matching the shape the chat
64
+ // instrumentors emit. The span processor strips it when message-content
65
+ // capture is disabled.
66
+ if (body && body.input != null) {
67
+ const inputs = Array.isArray(body.input) ? body.input : [body.input];
68
+ span.setAttribute(
69
+ 'gen_ai.input.messages',
70
+ JSON.stringify(
71
+ inputs.map((content) => ({ role: 'user', parts: [{ type: 'text', content }] }))
72
+ )
73
+ );
74
+ }
63
75
  return Promise.resolve(original.call(this, body, ...rest)).then(
64
76
  (response) => {
65
77
  if (response && response.model) {
@@ -77,7 +77,6 @@ function buildExporter({
77
77
  });
78
78
  const originalExport = exporter.export.bind(exporter);
79
79
  const wrappedExport = _exportWithRetry(originalExport, { maxRetries, maxRetryMs });
80
- let warned = false;
81
80
  exporter.export = (spans, resultCallback) => {
82
81
  const start = Date.now();
83
82
  debug('exporter.export: posting count=%d', spans.length);
@@ -93,11 +92,7 @@ function buildExporter({
93
92
  result?.code,
94
93
  result?.error?.message || result?.error || '(none)'
95
94
  );
96
- // Surface the first failure on stderr so users notice broken setups
97
- // even without NODE_DEBUG=weflayr. Stay quiet after — repeated warns
98
- // on a misconfigured key would just spam.
99
- if (warnOnExportError && !warned) {
100
- warned = true;
95
+ if (warnOnExportError) {
101
96
  // console.warn writes to stderr synchronously. process.emitWarning
102
97
  // is queued for the next tick, so a caller doing process.exit() from
103
98
  // their error handler races past it and the warning is lost.
@@ -22,6 +22,29 @@ const { EXTRA_TAG_PREFIX, getMergedMetadataFromContext } = require('./propagatio
22
22
  const { isDefaultExportSpan, isKnownLLMInstrumentor } = require('./span-filter');
23
23
  const { debug } = require('../logger');
24
24
 
25
+ // setup({captureMessageContent: ...}) records the choice in this env var (the
26
+ // official OTel GenAI one). Instrumentors gate content on their own knobs
27
+ // (openllmetry reads its own config and captures by default), so the processor
28
+ // enforces the flag itself: when the env var is "false", message content is
29
+ // stripped from every span before export.
30
+ const CONTENT_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
31
+
32
+ // The new-semconv JSON message attributes and the legacy indexed
33
+ // `gen_ai.prompt.{i}.*` / `gen_ai.completion.{i}.*` attributes.
34
+ const CONTENT_ATTRIBUTES = [
35
+ 'gen_ai.input.messages',
36
+ 'gen_ai.output.messages',
37
+ 'gen_ai.system_instructions',
38
+ ];
39
+ const CONTENT_ATTRIBUTE_PREFIXES = ['gen_ai.prompt.', 'gen_ai.completion.'];
40
+
41
+ function _isContentAttribute(key) {
42
+ return (
43
+ CONTENT_ATTRIBUTES.includes(key) ||
44
+ CONTENT_ATTRIBUTE_PREFIXES.some((prefix) => key.startsWith(prefix))
45
+ );
46
+ }
47
+
25
48
  class WeflayrSpanProcessor {
26
49
  constructor({ exporter, exportMode = 'batch', defaultTags = {}, mask, shouldExportSpan } = {}) {
27
50
  if (!exporter) {
@@ -77,6 +100,12 @@ class WeflayrSpanProcessor {
77
100
  }
78
101
  debug('span end: exporting name=%s scope=%s', span.name, span.instrumentationScope?.name);
79
102
 
103
+ if ((process.env[CONTENT_CAPTURE_ENV] || '').toLowerCase() === 'false') {
104
+ for (const key of Object.keys(span.attributes)) {
105
+ if (_isContentAttribute(key)) delete span.attributes[key];
106
+ }
107
+ }
108
+
80
109
  if (this._mask) {
81
110
  try {
82
111
  const masked = this._mask(span.attributes);
@@ -120,4 +149,4 @@ class WeflayrSpanProcessor {
120
149
  }
121
150
  }
122
151
 
123
- module.exports = { WeflayrSpanProcessor };
152
+ module.exports = { CONTENT_CAPTURE_ENV, WeflayrSpanProcessor };
package/src/setup.js CHANGED
@@ -5,13 +5,12 @@ const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
5
5
  const { resourceFromAttributes } = require('@opentelemetry/resources');
6
6
 
7
7
  const { buildExporter } = require('./otel/exporter');
8
- const { WeflayrSpanProcessor } = require('./otel/span-processor');
8
+ const { CONTENT_CAPTURE_ENV, WeflayrSpanProcessor } = require('./otel/span-processor');
9
9
  const { debug } = require('./logger');
10
10
  const { version: SDK_VERSION } = require('../package.json');
11
11
 
12
12
  const SDK_LANGUAGE = 'javascript';
13
13
  const DEFAULT_BASE_URL = 'https://api.weflayr.com';
14
- const CONTENT_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
15
14
 
16
15
  const _state = {
17
16
  tracerProvider: null,