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.
- package/index.d.ts +1 -1
- package/package.json +1 -1
- 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 +13 -3
- 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 +12 -3
- package/src/instrumentation/cohere/get_embeddings_telemetry.js +10 -0
- package/src/instrumentation/google/get_audio_telemetry.js +1 -2
- package/src/instrumentation/google/get_cache_telemetry.js +1 -3
- package/src/instrumentation/google/get_embeddings_telemetry.js +22 -1
- package/src/instrumentation/openai/get_embeddings_telemetry.js +12 -0
- package/src/otel/exporter.js +1 -6
- package/src/otel/span-processor.js +30 -1
- package/src/setup.js +1 -2
- package/tests/index.test.js +102 -0
- package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +33 -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_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_embeddings_telemetry.test.js +3 -0
- package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +18 -4
- package/tests/instrumentation/google/get_embeddings_telemetry.test.js +3 -0
- package/tests/instrumentation/google/set_telemetry_attributes.test.js +33 -1
- package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +19 -0
- 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
|
|
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
|
@@ -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
|
|
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 = [
|
|
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
|
|
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 = [
|
|
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
|
|
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`
|
|
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
|
-
*
|
|
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) {
|
package/src/otel/exporter.js
CHANGED
|
@@ -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
|
-
|
|
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,
|