weflayr 0.21.0 → 0.22.1

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 (101) hide show
  1. package/.prettierignore +2 -0
  2. package/dist/api/caseConversion.d.ts +16 -0
  3. package/dist/api/caseConversion.js +45 -0
  4. package/dist/api/generated/client/client.gen.d.ts +2 -0
  5. package/dist/api/generated/client/client.gen.js +220 -0
  6. package/dist/api/generated/client/index.d.ts +10 -0
  7. package/dist/api/generated/client/index.js +17 -0
  8. package/dist/api/generated/client/types.gen.d.ts +120 -0
  9. package/dist/api/generated/client/types.gen.js +3 -0
  10. package/dist/api/generated/client/utils.gen.d.ts +37 -0
  11. package/dist/api/generated/client/utils.gen.js +239 -0
  12. package/dist/api/generated/client.gen.d.ts +12 -0
  13. package/dist/api/generated/client.gen.js +6 -0
  14. package/dist/api/generated/core/auth.gen.d.ts +25 -0
  15. package/dist/api/generated/core/auth.gen.js +18 -0
  16. package/dist/api/generated/core/bodySerializer.gen.d.ts +25 -0
  17. package/dist/api/generated/core/bodySerializer.gen.js +60 -0
  18. package/dist/api/generated/core/params.gen.d.ts +43 -0
  19. package/dist/api/generated/core/params.gen.js +112 -0
  20. package/dist/api/generated/core/pathSerializer.gen.d.ts +33 -0
  21. package/dist/api/generated/core/pathSerializer.gen.js +115 -0
  22. package/dist/api/generated/core/queryKeySerializer.gen.d.ts +18 -0
  23. package/dist/api/generated/core/queryKeySerializer.gen.js +98 -0
  24. package/dist/api/generated/core/serverSentEvents.gen.d.ts +71 -0
  25. package/dist/api/generated/core/serverSentEvents.gen.js +135 -0
  26. package/dist/api/generated/core/types.gen.d.ts +83 -0
  27. package/dist/api/generated/core/types.gen.js +3 -0
  28. package/dist/api/generated/core/utils.gen.d.ts +19 -0
  29. package/dist/api/generated/core/utils.gen.js +93 -0
  30. package/dist/api/generated/facade.gen.d.ts +208 -0
  31. package/dist/api/generated/facade.gen.js +52 -0
  32. package/dist/api/generated/index.d.ts +2 -0
  33. package/dist/api/generated/index.js +17 -0
  34. package/dist/api/generated/sdk.gen.d.ts +63 -0
  35. package/dist/api/generated/sdk.gen.js +125 -0
  36. package/dist/api/generated/types.gen.d.ts +711 -0
  37. package/dist/api/generated/types.gen.js +3 -0
  38. package/dist/api/index.d.ts +27 -0
  39. package/dist/api/index.js +48 -0
  40. package/dist/api/makeEndpoint.d.ts +27 -0
  41. package/dist/api/makeEndpoint.js +37 -0
  42. package/index.d.ts +25 -16
  43. package/openapi-ts.config.mjs +9 -0
  44. package/package.json +9 -2
  45. package/scripts/generate_facade.mjs +86 -0
  46. package/src/api/caseConversion.ts +58 -0
  47. package/src/api/generated/client/client.gen.ts +277 -0
  48. package/src/api/generated/client/index.ts +27 -0
  49. package/src/api/generated/client/types.gen.ts +218 -0
  50. package/src/api/generated/client/utils.gen.ts +316 -0
  51. package/src/api/generated/client.gen.ts +16 -0
  52. package/src/api/generated/core/auth.gen.ts +48 -0
  53. package/src/api/generated/core/bodySerializer.gen.ts +82 -0
  54. package/src/api/generated/core/params.gen.ts +178 -0
  55. package/src/api/generated/core/pathSerializer.gen.ts +171 -0
  56. package/src/api/generated/core/queryKeySerializer.gen.ts +117 -0
  57. package/src/api/generated/core/serverSentEvents.gen.ts +242 -0
  58. package/src/api/generated/core/types.gen.ts +110 -0
  59. package/src/api/generated/core/utils.gen.ts +140 -0
  60. package/src/api/generated/facade.gen.ts +80 -0
  61. package/src/api/generated/index.ts +4 -0
  62. package/src/api/generated/sdk.gen.ts +139 -0
  63. package/src/api/generated/types.gen.ts +792 -0
  64. package/src/api/index.ts +45 -0
  65. package/src/api/makeEndpoint.ts +54 -0
  66. package/src/auto-instrument.js +19 -6
  67. package/src/index.js +5 -0
  68. package/src/instrumentation/anthropic/get_streaming_stop_reason_telemetry.js +121 -0
  69. package/src/instrumentation/anthropic/get_tool_definitions_telemetry.js +50 -0
  70. package/src/instrumentation/anthropic/set_telemetry_attributes.js +13 -3
  71. package/src/instrumentation/bedrock/get_input_message_content_telemetry.js +94 -0
  72. package/src/instrumentation/bedrock/get_streaming_output_message_telemetry.js +87 -0
  73. package/src/instrumentation/bedrock/set_telemetry_attributes.js +12 -3
  74. package/src/instrumentation/cohere/get_embeddings_telemetry.js +10 -0
  75. package/src/instrumentation/google/get_audio_telemetry.js +1 -2
  76. package/src/instrumentation/google/get_cache_telemetry.js +1 -3
  77. package/src/instrumentation/google/get_embeddings_telemetry.js +22 -1
  78. package/src/instrumentation/google/get_provider_name.js +2 -1
  79. package/src/instrumentation/openai/get_embeddings_telemetry.js +12 -0
  80. package/src/instrumentation/vercel/full_instrumentation.js +44 -0
  81. package/src/otel/exporter.js +1 -6
  82. package/src/otel/propagation.js +21 -12
  83. package/src/otel/span-processor.js +41 -2
  84. package/src/otel/vercel-ai-sdk-span-filter.js +79 -0
  85. package/src/setup.js +1 -2
  86. package/tests/api/index.test.js +330 -0
  87. package/tests/index.test.js +150 -15
  88. package/tests/instrumentation/anthropic/get_cache_and_reasoning_telemetry.test.js +33 -0
  89. package/tests/instrumentation/anthropic/get_streaming_stop_reason_telemetry.test.js +117 -0
  90. package/tests/instrumentation/anthropic/get_tool_definitions_telemetry.test.js +82 -0
  91. package/tests/instrumentation/bedrock/get_input_message_content_telemetry.test.js +89 -0
  92. package/tests/instrumentation/bedrock/get_streaming_output_message_telemetry.test.js +87 -0
  93. package/tests/instrumentation/cohere/get_embeddings_telemetry.test.js +3 -0
  94. package/tests/instrumentation/elevenlabs/full_instrumentation.test.js +18 -4
  95. package/tests/instrumentation/google/get_embeddings_telemetry.test.js +3 -0
  96. package/tests/instrumentation/google/set_telemetry_attributes.test.js +33 -1
  97. package/tests/instrumentation/openai/get_embeddings_telemetry.test.js +19 -0
  98. package/tests/instrumentation/openai/get_streaming_usage.test.js +29 -0
  99. package/tests/instrumentation/vercel/full_instrumentation.test.js +16 -0
  100. package/tests/otel/vercel-ai-sdk-span-filter.test.js +159 -0
  101. package/tsconfig.json +18 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Entry point for the Weflayr public API (revenue, key metrics, customer tags).
3
+ *
4
+ * The endpoint functions and types under ./generated are produced from the
5
+ * platform's OpenAPI spec (`make generate-sdk-api-clients`) and speak the
6
+ * wire's snake_case, same as the Python SDK. generated/facade.gen.ts (also
7
+ * produced by that command, via scripts/generate_facade.mjs) wraps every
8
+ * endpoint so JS callers use camelCase instead - see ./caseConversion and
9
+ * ./makeEndpoint. This module is the handwritten front door that configures
10
+ * their client:
11
+ *
12
+ * const weflayr = require('weflayr');
13
+ *
14
+ * const apiClient = weflayr.client({ apiKey: 'wf-...' });
15
+ * const { data } = await weflayr.api.getAllRevenue({ client: apiClient, throwOnError: true });
16
+ */
17
+
18
+ import { type Client, createClient, createConfig } from './generated/client';
19
+
20
+ export const DEFAULT_BASE_URL = 'https://app.weflayr.com/api';
21
+
22
+ export interface ApiClientOptions {
23
+ /** A project API key, created in the Weflayr dashboard. Defaults to the `WEFLAYR_API_KEY` environment variable. */
24
+ apiKey?: string;
25
+ /** Where the API lives — override for local or self-hosted deployments. */
26
+ baseUrl?: string;
27
+ }
28
+
29
+ /** A client for the public API, authenticated with a project API key. */
30
+ export function client(options: ApiClientOptions = {}): Client {
31
+ const apiKey = options.apiKey || process.env.WEFLAYR_API_KEY;
32
+ if (!apiKey) {
33
+ throw new Error(
34
+ 'weflayr.client: `apiKey` is required (pass it explicitly or set WEFLAYR_API_KEY)'
35
+ );
36
+ }
37
+ return createClient(
38
+ createConfig({
39
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
40
+ auth: () => apiKey,
41
+ })
42
+ );
43
+ }
44
+
45
+ export * from './generated/facade.gen';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wraps a generated public-API endpoint function so its `body`/`path`/`query`
3
+ * are converted from camelCase to the wire's snake_case on the way out, and
4
+ * its `data`/`error` are converted back to camelCase on the way in.
5
+ *
6
+ * Instantiated once per endpoint in generated/facade.gen.ts, produced by
7
+ * scripts/generate_facade.mjs - see that script for how the per-endpoint
8
+ * type arguments are derived.
9
+ */
10
+
11
+ import { toCamelCaseDeep, toSnakeCaseDeep } from './caseConversion';
12
+ import type { Client } from './generated/client';
13
+
14
+ export interface ClientOption {
15
+ client: Client;
16
+ throwOnError?: boolean;
17
+ headers?: HeadersInit;
18
+ }
19
+
20
+ export interface CallResult<TData, TError> {
21
+ data?: TData;
22
+ error?: TError;
23
+ request?: Request;
24
+ response?: Response;
25
+ }
26
+
27
+ type WireResult = { data?: unknown; error?: unknown };
28
+
29
+ export function makeEndpoint<TParams extends object, TData, TError>(
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ wireFn: (options: any) => Promise<WireResult>
32
+ ): (options: ClientOption & TParams) => Promise<CallResult<TData, TError>> {
33
+ return async (options) => {
34
+ const wireOptions = { ...options } as Record<string, unknown>;
35
+ for (const key of ['body', 'path', 'query']) {
36
+ if (wireOptions[key] !== undefined) {
37
+ wireOptions[key] = toSnakeCaseDeep(wireOptions[key]);
38
+ }
39
+ }
40
+
41
+ let result: WireResult;
42
+ try {
43
+ result = await wireFn(wireOptions);
44
+ } catch (error) {
45
+ throw toCamelCaseDeep(error as object);
46
+ }
47
+
48
+ return {
49
+ ...result,
50
+ data: result.data !== undefined ? (toCamelCaseDeep(result.data) as TData) : undefined,
51
+ error: result.error !== undefined ? (toCamelCaseDeep(result.error) as TError) : undefined,
52
+ };
53
+ };
54
+ }
@@ -12,16 +12,24 @@ const AiSdk = Object.freeze({
12
12
  COHERE: 'cohere',
13
13
  GOOGLE_GENAI: 'google-genai',
14
14
  ELEVENLABS: 'elevenlabs',
15
+ VERCEL_AI_GATEWAY: 'vercel_ai_gateway',
15
16
  });
16
17
 
17
- // Allowed values for `propagateMetadata({ providerName })`: every SDK the
18
- // auto-instrumentation registry knows, plus providers reached via the OpenAI
19
- // SDK on the wire but reported as themselves
20
- // Those are instrumented via `autoInstrument({ aiSdks: 'openai' })` and labelled
21
- // through `propagateMetadata({ providerName })`, not their own instrumentor.
18
+ // Allowed values for `propagateMetadata({ providerName })`: the actual AI
19
+ // providers a call can be attributed to. Unlike AiSdk this names providers,
20
+ // not SDKs — google-genai is an SDK whose calls are labelled gemini or vertex,
21
+ // and mistral has no Node OTel instrumentor so it is reached through the
22
+ // OpenAI SDK (OpenAI-compatible endpoint) but reported as itself.
22
23
  const AIProviderName = Object.freeze({
23
- ...AiSdk,
24
+ OPENAI: 'openai',
25
+ ANTHROPIC: 'anthropic',
26
+ BEDROCK: 'bedrock',
27
+ COHERE: 'cohere',
28
+ ELEVENLABS: 'elevenlabs',
29
+ MISTRAL: 'mistral',
24
30
  VERCEL_AI_GATEWAY: 'vercel_ai_gateway',
31
+ GEMINI: 'gemini',
32
+ VERTEX: 'vertex',
25
33
  });
26
34
 
27
35
  // The primary instrumentor for each provider SDK. Most are third-party
@@ -40,6 +48,11 @@ const REGISTRY = {
40
48
  requirePath: './instrumentation/elevenlabs/full_instrumentation',
41
49
  className: 'ElevenLabsInstrumentor',
42
50
  },
51
+ [AiSdk.VERCEL_AI_GATEWAY]: {
52
+ package: 'ai',
53
+ requirePath: './instrumentation/vercel/full_instrumentation',
54
+ className: 'VercelAiSdkInstrumentor',
55
+ },
43
56
  };
44
57
 
45
58
  // One complementary instrumentor per provider: a `set_telemetry_attributes`
package/src/index.js CHANGED
@@ -3,6 +3,9 @@
3
3
  const { setup, flush } = require('./setup');
4
4
  const { autoInstrument, AiSdk, AIProviderName } = require('./auto-instrument');
5
5
  const { propagateMetadata } = require('./otel/propagation');
6
+ // Public-API client (revenue, key metrics, customer tags), compiled from the
7
+ // TypeScript in src/api by `npm run build`.
8
+ const api = require('../dist/api');
6
9
 
7
10
  module.exports = {
8
11
  setup,
@@ -11,4 +14,6 @@ module.exports = {
11
14
  AiSdk,
12
15
  AIProviderName,
13
16
  propagateMetadata,
17
+ client: api.client,
18
+ api,
14
19
  };
@@ -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();
@@ -7,7 +7,8 @@
7
7
  * `gen_ai.provider.name = "gcp.gen_ai"` and can't tell Vertex from the Gemini
8
8
  * Developer API. We read the client's Vertex flag and return the value to set as
9
9
  * `gen_ai.system` (which the normalizer prefers): `vertex_ai` / `gemini`,
10
- * matching what the Python google-genai instrumentor reports natively. Node-only.
10
+ * matching what the Python google-genai instrumentor reports natively. The
11
+ * worker normalizes `vertex_ai` to the canonical `vertex`. Node-only.
11
12
  */
12
13
 
13
14
  // Read the Vertex-vs-Dev backend off the Models instance's api client.