From faabebf7b6723838e74469d23b35c83021873d29 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Nov 2026 16:12:45 +0100 Subject: [PATCH 1/6] feat(aws-serverless): Record Bedrock prompts, system instructions, and tool definitions Co-Authored-By: Claude Opus 5.5 --- .../services/bedrock-runtime-content.ts | 226 ++++++++++++++++++ .../aws-sdk/services/bedrock-runtime.ts | 26 +- .../test/integrations/aws-sdk-bedrock.test.ts | 178 +++++++++++++- 3 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts new file mode 100644 index 000000000000..4f5e81c4029f --- /dev/null +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts @@ -0,0 +1,226 @@ +import { isObjectLike, stringify } from '@sentry/core'; +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, +} from '@sentry/conventions/attributes'; + +/** + * A message part as described in https://develop.sentry.dev/sdk/telemetry/traces/modules/ai-agents/. + * Bedrock's own content blocks (`{ text }`, `{ toolUse }`, ...) carry no `type`, and parts Sentry + * cannot type are dropped when a message is rendered, so they are translated into this shape. + */ +type MessagePart = Record & { type: string }; + +/** + * Translate Converse `ContentBlock`s (also used by Amazon Nova `InvokeModel` bodies) into message parts. + * + * Media is reported by modality only: inline media is raw bytes, which the conventions require to be + * dropped rather than recorded. Blocks without content worth recording (`cachePoint`, redacted + * reasoning, unknown members) are left out rather than serialized, so no binary payload slips through. + */ +function contentBlocksToParts(content: unknown): MessagePart[] { + return Array.isArray(content) ? content.flatMap(contentBlockToParts) : []; +} + +function contentBlockToParts(block: unknown): MessagePart[] { + if (!isObjectLike(block)) { + return []; + } + + if (typeof block.text === 'string') { + return [{ type: 'text', content: block.text }]; + } + + const { toolUse, toolResult, reasoningContent, guardContent, citationsContent } = block; + + if (isObjectLike(toolUse)) { + return [ + { + type: 'tool_call', + id: toolUse.toolUseId, + name: toolUse.name, + arguments: stringify(toolUse.input ?? {}, String), + }, + ]; + } + + if (isObjectLike(toolResult)) { + return [{ type: 'tool_call_response', id: toolResult.toolUseId, result: toolResultToString(toolResult.content) }]; + } + + if (isObjectLike(reasoningContent)) { + const reasoningText = reasoningContent.reasoningText; + return isObjectLike(reasoningText) && typeof reasoningText.text === 'string' + ? [{ type: 'reasoning', content: reasoningText.text }] + : []; + } + + if (isObjectLike(guardContent)) { + if (isObjectLike(guardContent.text) && typeof guardContent.text.text === 'string') { + return [{ type: 'text', content: guardContent.text.text }]; + } + return contentBlockToParts(guardContent); + } + + if (isObjectLike(citationsContent)) { + return contentBlocksToParts(citationsContent.content); + } + + if (isObjectLike(block.image)) { + const format = block.image.format; + return [ + typeof format === 'string' + ? { type: 'blob', modality: 'image', mime_type: `image/${format}` } + : { type: 'blob', modality: 'image' }, + ]; + } + + for (const modality of ['video', 'audio', 'document']) { + if (isObjectLike(block[modality])) { + return [{ type: 'blob', modality }]; + } + } + + return []; +} + +function toolResultToString(content: unknown): string { + if (!Array.isArray(content)) { + return ''; + } + + return content + .map(block => { + if (!isObjectLike(block)) { + return undefined; + } + if (typeof block.text === 'string') { + return block.text; + } + return block.json !== undefined ? stringify(block.json, String) : undefined; + }) + .filter((text): text is string => text !== undefined) + .join('\n'); +} + +function converseMessagesToMessages(messages: unknown): Array<{ role: string; parts: MessagePart[] }> { + if (!Array.isArray(messages)) { + return []; + } + + return messages + .filter(isObjectLike) + .map(message => ({ + role: typeof message.role === 'string' ? message.role : 'user', + parts: contentBlocksToParts(message.content), + })) + .filter(message => message.parts.length > 0); +} + +function systemInstructionsAttribute(parts: MessagePart[]): string | undefined { + return parts.length ? JSON.stringify(parts) : undefined; +} + +/** + * Input attributes of a Converse-shaped request (`Converse`, `ConverseStream`, and Amazon Nova + * `InvokeModel` bodies, which use the same `messages`/`system`/`toolConfig` shape). + */ +export function getConverseInputAttributes(input: Record): Record { + const attributes: Record = {}; + + const systemInstructions = systemInstructionsAttribute(contentBlocksToParts(input.system)); + if (systemInstructions) { + attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = systemInstructions; + } + + const messages = converseMessagesToMessages(input.messages); + if (messages.length) { + attributes[GEN_AI_INPUT_MESSAGES] = JSON.stringify(messages); + } + + const toolConfig = input.toolConfig; + if (isObjectLike(toolConfig) && Array.isArray(toolConfig.tools)) { + // `cachePoint` entries share the list with the `toolSpec`s; they are not tools. + const toolSpecs = toolConfig.tools + .map(tool => (isObjectLike(tool) ? tool.toolSpec : undefined)) + .filter(isObjectLike); + if (toolSpecs.length) { + attributes[GEN_AI_TOOL_DEFINITIONS] = JSON.stringify(toolSpecs); + } + } + + return attributes; +} + +/** + * Anthropic image and document blocks carry their file inline as base64 `source.data`, which must not be + * recorded, so they are replaced with blob parts, also inside tool results. + */ +function claudeContentWithoutMedia(content: unknown): unknown { + return Array.isArray(content) ? content.map(claudeBlockWithoutMedia) : content; +} + +function claudeBlockWithoutMedia(block: unknown): unknown { + if (!isObjectLike(block)) { + return block; + } + if (block.type === 'image' || block.type === 'document') { + const mimeType = isObjectLike(block.source) ? block.source.media_type : undefined; + return typeof mimeType === 'string' + ? { type: 'blob', modality: block.type, mime_type: mimeType } + : { type: 'blob', modality: block.type }; + } + if (block.type === 'tool_result') { + return { ...block, content: claudeContentWithoutMedia(block.content) }; + } + return block; +} + +/** + * Input attributes of an Anthropic Messages `InvokeModel` body. Apart from media, the messages are + * recorded as sent, like the Anthropic integration records the same payload, so both render alike. + */ +export function getClaudeInputAttributes(body: Record): Record { + const attributes: Record = {}; + + const { system, messages, tools, prompt } = body; + + const systemTexts = + typeof system === 'string' + ? [system] + : Array.isArray(system) + ? system + .map(block => (isObjectLike(block) && typeof block.text === 'string' ? block.text : undefined)) + .filter((text): text is string => !!text) + : []; + const systemInstructions = systemInstructionsAttribute(systemTexts.map(content => ({ type: 'text', content }))); + if (systemInstructions) { + attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = systemInstructions; + } + + if (Array.isArray(messages) && messages.length) { + attributes[GEN_AI_INPUT_MESSAGES] = stringify( + messages.map(message => + isObjectLike(message) ? { ...message, content: claudeContentWithoutMedia(message.content) } : message, + ), + ); + } else if (typeof prompt === 'string') { + // Claude 2's legacy Text Completions body. + Object.assign(attributes, getPromptInputAttributes(prompt)); + } + + if (Array.isArray(tools) && tools.length) { + attributes[GEN_AI_TOOL_DEFINITIONS] = JSON.stringify(tools); + } + + return attributes; +} + +/** Input attributes of the single-prompt `InvokeModel` bodies (Titan, Llama, Cohere, Mistral). */ +export function getPromptInputAttributes(prompt: unknown): Record { + if (typeof prompt !== 'string') { + return {}; + } + return { [GEN_AI_INPUT_MESSAGES]: JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: prompt }] }]) }; +} diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts index 538b5f99726c..3acf7f799aa0 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts @@ -14,7 +14,7 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_CHAT, GEN_AI_GENERATE_CONTENT } from '@sentry/conventions/op'; import type { StreamResponseState } from '../../../ai/core/utils'; -import { endStreamSpan, getTokenUsageAttributes } from '../../../ai/core/utils'; +import { endStreamSpan, getTokenUsageAttributes, resolveAIRecordingOptions } from '../../../ai/core/utils'; import { DEBUG_BUILD } from '../../../debug-build'; import { GEN_AI_OPERATION_NAME_VALUE_CHAT, @@ -22,6 +22,11 @@ import { GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, } from '../constants'; import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { + getClaudeInputAttributes, + getConverseInputAttributes, + getPromptInputAttributes, +} from './bedrock-runtime-content'; import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; // Simplified types inlined from @aws-sdk/client-bedrock-runtime @@ -120,6 +125,10 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension { } } + if (resolveAIRecordingOptions().recordInputs) { + Object.assign(spanAttributes, getConverseInputAttributes(request.commandInput)); + } + return { spanName, spanOp: GEN_AI_CHAT, @@ -244,6 +253,10 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension { spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop; } } + + if (resolveAIRecordingOptions().recordInputs) { + Object.assign(spanAttributes, getInvokeModelInputAttributes(modelId, requestBody)); + } } return { @@ -398,6 +411,17 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension { } } +function getInvokeModelInputAttributes(modelId: string, requestBody: ParsedChunk): Record { + if (modelId.includes('amazon.titan')) return getPromptInputAttributes(requestBody.inputText); + if (modelId.includes('amazon.nova')) return getConverseInputAttributes(requestBody); + if (modelId.includes('anthropic.claude')) return getClaudeInputAttributes(requestBody); + if (modelId.includes('meta.llama')) return getPromptInputAttributes(requestBody.prompt); + if (modelId.includes('cohere.command-r')) return getPromptInputAttributes(requestBody.message); + if (modelId.includes('cohere.command')) return getPromptInputAttributes(requestBody.prompt); + if (modelId.includes('mistral')) return getPromptInputAttributes(requestBody.prompt); + return {}; +} + type StreamProcessor = (parsedChunk: ParsedChunk, state: StreamResponseState) => void; function resolveStreamProcessor(modelId: string): StreamProcessor | undefined { diff --git a/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts b/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts index e7fbf41d50d0..5bfd1dd2d2e2 100644 --- a/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts +++ b/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts @@ -1,10 +1,13 @@ import { + GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_TEMPERATURE, GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, @@ -28,14 +31,18 @@ const CLAUDE_REQUEST_BODY = { messages: [{ role: 'user', content: 'What is the capital of France?' }], }; -function invokeModelPreSpanHook(commandInput: Record): RequestMetadata { +function preSpanHook(commandName: string, commandInput: Record): RequestMetadata { return new BedrockRuntimeServiceExtension().requestPreSpanHook({ serviceName: 'BedrockRuntime', - commandName: 'InvokeModel', + commandName, commandInput, }); } +function invokeModelPreSpanHook(commandInput: Record): RequestMetadata { + return preSpanHook('InvokeModel', commandInput); +} + describe('BedrockRuntimeServiceExtension InvokeModel request', () => { it('reads a request body passed as bytes', () => { const body = new TextEncoder().encode(JSON.stringify(CLAUDE_REQUEST_BODY)); @@ -48,6 +55,7 @@ describe('BedrockRuntimeServiceExtension InvokeModel request', () => { [GEN_AI_REQUEST_MODEL]: CLAUDE_MODEL_ID, [GEN_AI_REQUEST_MAX_TOKENS]: 1024, [GEN_AI_REQUEST_TEMPERATURE]: 0.5, + [GEN_AI_INPUT_MESSAGES]: JSON.stringify(CLAUDE_REQUEST_BODY.messages), }); }); @@ -81,6 +89,172 @@ describe('BedrockRuntimeServiceExtension InvokeModel request', () => { }); }); +const WEATHER_TOOL_SPEC = { + name: 'get_weather', + description: 'Get the weather for a city', + inputSchema: { json: { type: 'object', properties: { city: { type: 'string' } } } }, +}; + +describe('BedrockRuntimeServiceExtension input recording', () => { + afterEach(() => { + setCurrentClient(undefined as never); + }); + + it('records Converse system instructions, messages, and tool definitions as message parts', () => { + const { spanAttributes } = preSpanHook('Converse', { + modelId: 'amazon.nova-pro-v1:0', + system: [{ text: 'You are a weather bot.' }, { cachePoint: { type: 'default' } }], + messages: [ + { + role: 'user', + content: [ + { text: 'What is the weather in Kawasaki?' }, + { image: { format: 'png', source: { bytes: new Uint8Array([1, 2, 3]) } } }, + { image: { source: { bytes: new Uint8Array([1]) } } }, + { document: { name: 'forecast', source: { bytes: new Uint8Array([1]) } } }, + { guardContent: { text: { text: 'Guarded text' } } }, + { guardContent: { image: { format: 'jpeg', source: { bytes: new Uint8Array([1]) } } } }, + { cachePoint: { type: 'default' } }, + ], + }, + { + role: 'assistant', + content: [ + { reasoningContent: { reasoningText: { text: 'I need the weather tool.' } } }, + { reasoningContent: { redactedContent: new Uint8Array([1]) } }, + { toolUse: { toolUseId: 'tooluse_1', name: 'get_weather', input: { city: 'Kawasaki' } } }, + ], + }, + { + role: 'user', + content: [ + { + toolResult: { + toolUseId: 'tooluse_1', + content: [{ text: 'Sunny' }, { json: { celsius: 21 } }, { image: { format: 'png' } }], + }, + }, + { citationsContent: { content: [{ text: 'Cited text' }] } }, + ], + }, + { role: 'user', content: [{ cachePoint: { type: 'default' } }] }, + ], + toolConfig: { tools: [{ toolSpec: WEATHER_TOOL_SPEC }, { cachePoint: { type: 'default' } }] }, + }); + + expect(spanAttributes).toEqual({ + [GEN_AI_PROVIDER_NAME]: 'aws.bedrock', + [GEN_AI_OPERATION_NAME]: 'chat', + [GEN_AI_REQUEST_MODEL]: 'amazon.nova-pro-v1:0', + [GEN_AI_SYSTEM_INSTRUCTIONS]: JSON.stringify([{ type: 'text', content: 'You are a weather bot.' }]), + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + { + role: 'user', + parts: [ + { type: 'text', content: 'What is the weather in Kawasaki?' }, + { type: 'blob', modality: 'image', mime_type: 'image/png' }, + { type: 'blob', modality: 'image' }, + { type: 'blob', modality: 'document' }, + { type: 'text', content: 'Guarded text' }, + { type: 'blob', modality: 'image', mime_type: 'image/jpeg' }, + ], + }, + { + role: 'assistant', + parts: [ + { type: 'reasoning', content: 'I need the weather tool.' }, + { type: 'tool_call', id: 'tooluse_1', name: 'get_weather', arguments: '{"city":"Kawasaki"}' }, + ], + }, + { + role: 'user', + parts: [ + { type: 'tool_call_response', id: 'tooluse_1', result: 'Sunny\n{"celsius":21}' }, + { type: 'text', content: 'Cited text' }, + ], + }, + ]), + [GEN_AI_TOOL_DEFINITIONS]: JSON.stringify([WEATHER_TOOL_SPEC]), + }); + }); + + it('records Claude InvokeModel messages as sent, with media replaced by blob parts', () => { + const tools = [{ name: 'get_weather', input_schema: { type: 'object' } }]; + const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }; + const document = { type: 'document', source: { type: 'base64', data: 'JVBERi0=' } }; + const body = { + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 1024, + system: [{ type: 'text', text: 'You are a weather bot.' }], + messages: [ + { role: 'user', content: [{ type: 'text', text: 'What is the weather in Kawasaki?' }, image] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [document] }] }, + ], + tools, + }; + + const { spanAttributes } = invokeModelPreSpanHook({ modelId: CLAUDE_MODEL_ID, body: JSON.stringify(body) }); + + expect(spanAttributes).toEqual({ + [GEN_AI_PROVIDER_NAME]: 'aws.bedrock', + [GEN_AI_OPERATION_NAME]: 'generate_content', + [GEN_AI_REQUEST_MODEL]: CLAUDE_MODEL_ID, + [GEN_AI_REQUEST_MAX_TOKENS]: 1024, + [GEN_AI_SYSTEM_INSTRUCTIONS]: JSON.stringify([{ type: 'text', content: 'You are a weather bot.' }]), + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + { + role: 'user', + content: [ + { type: 'text', text: 'What is the weather in Kawasaki?' }, + { type: 'blob', modality: 'image', mime_type: 'image/png' }, + ], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'blob', modality: 'document' }] }], + }, + ]), + [GEN_AI_TOOL_DEFINITIONS]: JSON.stringify(tools), + }); + }); + + it.each([ + { family: 'Titan', modelId: 'amazon.titan-text-express-v1', body: { inputText: 'Kawasaki' } }, + { family: 'Llama', modelId: 'meta.llama3-8b-instruct-v1:0', body: { prompt: 'Kawasaki' } }, + { family: 'Cohere Command R', modelId: 'cohere.command-r-v1:0', body: { message: 'Kawasaki' } }, + { family: 'Cohere Command', modelId: 'cohere.command-text-v14', body: { prompt: 'Kawasaki' } }, + { family: 'Mistral', modelId: 'mistral.mistral-7b-instruct-v0:2', body: { prompt: 'Kawasaki' } }, + { family: 'Claude Text Completions', modelId: 'anthropic.claude-v2', body: { prompt: 'Kawasaki' } }, + { + family: 'Nova', + modelId: 'amazon.nova-lite-v1:0', + body: { messages: [{ role: 'user', content: [{ text: 'Kawasaki' }] }] }, + }, + ])('records the $family InvokeModel prompt as a user message', ({ modelId, body }) => { + const { spanAttributes } = invokeModelPreSpanHook({ modelId, body: JSON.stringify(body) }); + + expect(spanAttributes?.[GEN_AI_INPUT_MESSAGES]).toBe( + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'Kawasaki' }] }]), + ); + }); + + it.each([ + { commandName: 'Converse', commandInput: { messages: [{ role: 'user', content: [{ text: 'Kawasaki' }] }] } }, + { + commandName: 'InvokeModel', + commandInput: { modelId: CLAUDE_MODEL_ID, body: JSON.stringify(CLAUDE_REQUEST_BODY) }, + }, + ])('records no $commandName input when dataCollection.genAI.inputs is off', ({ commandName, commandInput }) => { + const client = new TestClient(getDefaultTestClientOptions({ dataCollection: { genAI: { inputs: false } } })); + setCurrentClient(client); + client.init(); + + const { spanAttributes } = preSpanHook(commandName, commandInput); + + expect(spanAttributes).not.toHaveProperty(GEN_AI_INPUT_MESSAGES); + }); +}); + const STREAM_COMMANDS = [ { commandName: 'ConverseStream', streamField: 'stream' }, { commandName: 'InvokeModelWithResponseStream', streamField: 'body' }, From b8764bf6f9e55534a8b4b3944ff90f87eaec7821 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Nov 2026 16:26:31 +0100 Subject: [PATCH 2/6] Record Claude InvokeModel inputs with the Anthropic integration helpers --- .../server-utils/src/ai/anthropic-ai/index.ts | 4 +- .../server-utils/src/ai/anthropic-ai/utils.ts | 17 +++-- .../services/bedrock-runtime-content.ts | 63 ++----------------- .../test/ai/lib/utils/anthropic-utils.test.ts | 28 ++------- .../test/integrations/aws-sdk-bedrock.test.ts | 32 ++-------- 5 files changed, 24 insertions(+), 120 deletions(-) diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index 5723adf1fde3..6b4f7d357ce6 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -29,7 +29,7 @@ import { ANTHROPIC_AI_ORIGIN, ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import { instrumentSessionToolRunner, instrumentToolRunner, instrumentToolRunnerFactory } from './tool-runner'; import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; -import { handleResponseError, messagesFromParams, setMessagesAttribute } from './utils'; +import { getMessagesAttributes, handleResponseError, messagesFromParams } from './utils'; // Set only while a streaming helper (e.g. `messages.stream()`) synchronously delegates to the // underlying `create`. The SDK invokes that internal `create` synchronously, so a plain flag @@ -80,7 +80,7 @@ export function extractRequestAttributes( */ export function addPrivateRequestAttributes(span: Span, params: Record): void { const messages = messagesFromParams(params); - setMessagesAttribute(span, messages); + span.setAttributes(getMessagesAttributes(messages)); } /** diff --git a/packages/server-utils/src/ai/anthropic-ai/utils.ts b/packages/server-utils/src/ai/anthropic-ai/utils.ts index 65001dba746c..491b357d79a7 100644 --- a/packages/server-utils/src/ai/anthropic-ai/utils.ts +++ b/packages/server-utils/src/ai/anthropic-ai/utils.ts @@ -5,24 +5,21 @@ import { extractSystemInstructions } from '../core/utils'; import type { AnthropicAiResponse } from './types'; /** - * Set the input messages attribute, extracting system instructions into their own attribute. + * Get the input messages attribute, with system instructions extracted into their own attribute. */ -export function setMessagesAttribute(span: Span, messages: unknown): void { +export function getMessagesAttributes(messages: unknown): Record { if (Array.isArray(messages) && messages.length === 0) { - return; + return {}; } const { systemInstructions, filteredMessages } = extractSystemInstructions(messages); + const attributes: Record = {}; if (systemInstructions) { - span.setAttributes({ - [GEN_AI_SYSTEM_INSTRUCTIONS]: systemInstructions, - }); + attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = systemInstructions; } - - span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), - }); + attributes[GEN_AI_INPUT_MESSAGES] = stringify(filteredMessages); + return attributes; } const ANTHROPIC_ERROR_TYPE_TO_SPAN_STATUS: Record = { diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts index 4f5e81c4029f..1d3c2531d8d2 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts @@ -4,6 +4,7 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_TOOL_DEFINITIONS, } from '@sentry/conventions/attributes'; +import { getMessagesAttributes, messagesFromParams } from '../../../ai/anthropic-ai/utils'; /** * A message part as described in https://develop.sentry.dev/sdk/telemetry/traces/modules/ai-agents/. @@ -154,66 +155,14 @@ export function getConverseInputAttributes(input: Record): Reco } /** - * Anthropic image and document blocks carry their file inline as base64 `source.data`, which must not be - * recorded, so they are replaced with blob parts, also inside tool results. - */ -function claudeContentWithoutMedia(content: unknown): unknown { - return Array.isArray(content) ? content.map(claudeBlockWithoutMedia) : content; -} - -function claudeBlockWithoutMedia(block: unknown): unknown { - if (!isObjectLike(block)) { - return block; - } - if (block.type === 'image' || block.type === 'document') { - const mimeType = isObjectLike(block.source) ? block.source.media_type : undefined; - return typeof mimeType === 'string' - ? { type: 'blob', modality: block.type, mime_type: mimeType } - : { type: 'blob', modality: block.type }; - } - if (block.type === 'tool_result') { - return { ...block, content: claudeContentWithoutMedia(block.content) }; - } - return block; -} - -/** - * Input attributes of an Anthropic Messages `InvokeModel` body. Apart from media, the messages are - * recorded as sent, like the Anthropic integration records the same payload, so both render alike. + * Claude `InvokeModel` bodies are Anthropic Messages API requests, so they are recorded like the Anthropic + * integration records them. */ export function getClaudeInputAttributes(body: Record): Record { - const attributes: Record = {}; - - const { system, messages, tools, prompt } = body; - - const systemTexts = - typeof system === 'string' - ? [system] - : Array.isArray(system) - ? system - .map(block => (isObjectLike(block) && typeof block.text === 'string' ? block.text : undefined)) - .filter((text): text is string => !!text) - : []; - const systemInstructions = systemInstructionsAttribute(systemTexts.map(content => ({ type: 'text', content }))); - if (systemInstructions) { - attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = systemInstructions; + const attributes = getMessagesAttributes(messagesFromParams(body)); + if (Array.isArray(body.tools)) { + attributes[GEN_AI_TOOL_DEFINITIONS] = JSON.stringify(body.tools); } - - if (Array.isArray(messages) && messages.length) { - attributes[GEN_AI_INPUT_MESSAGES] = stringify( - messages.map(message => - isObjectLike(message) ? { ...message, content: claudeContentWithoutMedia(message.content) } : message, - ), - ); - } else if (typeof prompt === 'string') { - // Claude 2's legacy Text Completions body. - Object.assign(attributes, getPromptInputAttributes(prompt)); - } - - if (Array.isArray(tools) && tools.length) { - attributes[GEN_AI_TOOL_DEFINITIONS] = JSON.stringify(tools); - } - return attributes; } diff --git a/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts b/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts index 591c5ae004a0..e6eae3860949 100644 --- a/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + getMessagesAttributes, mapAnthropicErrorToStatusMessage, messagesFromParams, - setMessagesAttribute, } from '../../../../src/ai/anthropic-ai/utils'; -import type { Span } from '@sentry/core'; describe('anthropic-ai-utils', () => { describe('mapAnthropicErrorToStatusMessage', () => { @@ -98,32 +97,15 @@ describe('anthropic-ai-utils', () => { }); }); - describe('setMessagesAtribute', () => { - const mock = { - attributes: {} as Record, - setAttributes(kv: Record) { - for (const [key, val] of Object.entries(kv)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - if (val === undefined) delete this.attributes[key]; - else this.attributes[key] = val; - } - }, - }; - const span = mock as unknown as Span; - - it('sets the input messages attribute', () => { - setMessagesAttribute(span, [{ role: 'user', content: 'hello, world' }]); - expect(mock.attributes).toStrictEqual({ + describe('getMessagesAttributes', () => { + it('returns the input messages attribute', () => { + expect(getMessagesAttributes([{ role: 'user', content: 'hello, world' }])).toStrictEqual({ 'gen_ai.input.messages': '[{"role":"user","content":"hello, world"}]', }); }); it('ignores empty array', () => { - setMessagesAttribute(span, [{ role: 'user', content: 'hello, world' }]); - const before = { ...mock.attributes }; - - setMessagesAttribute(span, []); - expect(mock.attributes).toStrictEqual(before); + expect(getMessagesAttributes([])).toStrictEqual({}); }); }); }); diff --git a/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts b/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts index 5bfd1dd2d2e2..3367410fc0f0 100644 --- a/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts +++ b/packages/server-utils/test/integrations/aws-sdk-bedrock.test.ts @@ -178,20 +178,10 @@ describe('BedrockRuntimeServiceExtension input recording', () => { }); }); - it('records Claude InvokeModel messages as sent, with media replaced by blob parts', () => { + it('records Claude InvokeModel system instructions, messages, and tool definitions like the Anthropic integration', () => { const tools = [{ name: 'get_weather', input_schema: { type: 'object' } }]; - const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }; - const document = { type: 'document', source: { type: 'base64', data: 'JVBERi0=' } }; - const body = { - anthropic_version: 'bedrock-2023-05-31', - max_tokens: 1024, - system: [{ type: 'text', text: 'You are a weather bot.' }], - messages: [ - { role: 'user', content: [{ type: 'text', text: 'What is the weather in Kawasaki?' }, image] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [document] }] }, - ], - tools, - }; + const messages = [{ role: 'user', content: [{ type: 'text', text: 'What is the weather in Kawasaki?' }] }]; + const body = { system: [{ type: 'text', text: 'You are a weather bot.' }], messages, tools }; const { spanAttributes } = invokeModelPreSpanHook({ modelId: CLAUDE_MODEL_ID, body: JSON.stringify(body) }); @@ -199,21 +189,8 @@ describe('BedrockRuntimeServiceExtension input recording', () => { [GEN_AI_PROVIDER_NAME]: 'aws.bedrock', [GEN_AI_OPERATION_NAME]: 'generate_content', [GEN_AI_REQUEST_MODEL]: CLAUDE_MODEL_ID, - [GEN_AI_REQUEST_MAX_TOKENS]: 1024, [GEN_AI_SYSTEM_INSTRUCTIONS]: JSON.stringify([{ type: 'text', content: 'You are a weather bot.' }]), - [GEN_AI_INPUT_MESSAGES]: JSON.stringify([ - { - role: 'user', - content: [ - { type: 'text', text: 'What is the weather in Kawasaki?' }, - { type: 'blob', modality: 'image', mime_type: 'image/png' }, - ], - }, - { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'blob', modality: 'document' }] }], - }, - ]), + [GEN_AI_INPUT_MESSAGES]: JSON.stringify(messages), [GEN_AI_TOOL_DEFINITIONS]: JSON.stringify(tools), }); }); @@ -224,7 +201,6 @@ describe('BedrockRuntimeServiceExtension input recording', () => { { family: 'Cohere Command R', modelId: 'cohere.command-r-v1:0', body: { message: 'Kawasaki' } }, { family: 'Cohere Command', modelId: 'cohere.command-text-v14', body: { prompt: 'Kawasaki' } }, { family: 'Mistral', modelId: 'mistral.mistral-7b-instruct-v0:2', body: { prompt: 'Kawasaki' } }, - { family: 'Claude Text Completions', modelId: 'anthropic.claude-v2', body: { prompt: 'Kawasaki' } }, { family: 'Nova', modelId: 'amazon.nova-lite-v1:0', From da8c4f3541b0caea53623675c7c94d68bfee259d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Nov 2026 16:32:01 +0100 Subject: [PATCH 3/6] Use the shared GenAiMessagePart type for Bedrock message parts --- .../aws-sdk/services/bedrock-runtime-content.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts index 1d3c2531d8d2..c4f6eb778c7c 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts @@ -5,26 +5,21 @@ import { GEN_AI_TOOL_DEFINITIONS, } from '@sentry/conventions/attributes'; import { getMessagesAttributes, messagesFromParams } from '../../../ai/anthropic-ai/utils'; - -/** - * A message part as described in https://develop.sentry.dev/sdk/telemetry/traces/modules/ai-agents/. - * Bedrock's own content blocks (`{ text }`, `{ toolUse }`, ...) carry no `type`, and parts Sentry - * cannot type are dropped when a message is rendered, so they are translated into this shape. - */ -type MessagePart = Record & { type: string }; +import type { GenAiMessagePart } from '../../../ai/types'; /** * Translate Converse `ContentBlock`s (also used by Amazon Nova `InvokeModel` bodies) into message parts. + * Bedrock's blocks (`{ text }`, `{ toolUse }`, ...) carry no `type`, and Sentry drops parts it cannot type. * * Media is reported by modality only: inline media is raw bytes, which the conventions require to be * dropped rather than recorded. Blocks without content worth recording (`cachePoint`, redacted * reasoning, unknown members) are left out rather than serialized, so no binary payload slips through. */ -function contentBlocksToParts(content: unknown): MessagePart[] { +function contentBlocksToParts(content: unknown): GenAiMessagePart[] { return Array.isArray(content) ? content.flatMap(contentBlockToParts) : []; } -function contentBlockToParts(block: unknown): MessagePart[] { +function contentBlockToParts(block: unknown): GenAiMessagePart[] { if (!isObjectLike(block)) { return []; } @@ -105,7 +100,7 @@ function toolResultToString(content: unknown): string { .join('\n'); } -function converseMessagesToMessages(messages: unknown): Array<{ role: string; parts: MessagePart[] }> { +function converseMessagesToMessages(messages: unknown): Array<{ role: string; parts: GenAiMessagePart[] }> { if (!Array.isArray(messages)) { return []; } @@ -119,7 +114,7 @@ function converseMessagesToMessages(messages: unknown): Array<{ role: string; pa .filter(message => message.parts.length > 0); } -function systemInstructionsAttribute(parts: MessagePart[]): string | undefined { +function systemInstructionsAttribute(parts: GenAiMessagePart[]): string | undefined { return parts.length ? JSON.stringify(parts) : undefined; } From e5cd9cc249a560ece5c41be497297670f21e61a9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 10 Nov 2026 16:43:03 +0100 Subject: [PATCH 4/6] Shrink the Bedrock input recording code --- .../services/bedrock-runtime-content.ts | 36 +++++++------------ .../aws-sdk/services/bedrock-runtime.ts | 8 ++--- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts index c4f6eb778c7c..53fe36a3a0f6 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts @@ -53,28 +53,20 @@ function contentBlockToParts(block: unknown): GenAiMessagePart[] { } if (isObjectLike(guardContent)) { - if (isObjectLike(guardContent.text) && typeof guardContent.text.text === 'string') { - return [{ type: 'text', content: guardContent.text.text }]; - } - return contentBlockToParts(guardContent); + // `guardContent.text` is a `{ text }` block, `guardContent.image` an image block. + return contentBlockToParts(isObjectLike(guardContent.text) ? guardContent.text : guardContent); } if (isObjectLike(citationsContent)) { return contentBlocksToParts(citationsContent.content); } - if (isObjectLike(block.image)) { - const format = block.image.format; - return [ - typeof format === 'string' - ? { type: 'blob', modality: 'image', mime_type: `image/${format}` } - : { type: 'blob', modality: 'image' }, - ]; - } - - for (const modality of ['video', 'audio', 'document']) { - if (isObjectLike(block[modality])) { - return [{ type: 'blob', modality }]; + for (const modality of ['image', 'video', 'audio', 'document']) { + const media = block[modality]; + if (isObjectLike(media)) { + // Only image formats (`png`, ...) map to a MIME type by prefix; `undefined` is left out of the JSON. + const format = modality === 'image' && typeof media.format === 'string' ? `image/${media.format}` : undefined; + return [{ type: 'blob', modality, mime_type: format }]; } } @@ -114,10 +106,6 @@ function converseMessagesToMessages(messages: unknown): Array<{ role: string; pa .filter(message => message.parts.length > 0); } -function systemInstructionsAttribute(parts: GenAiMessagePart[]): string | undefined { - return parts.length ? JSON.stringify(parts) : undefined; -} - /** * Input attributes of a Converse-shaped request (`Converse`, `ConverseStream`, and Amazon Nova * `InvokeModel` bodies, which use the same `messages`/`system`/`toolConfig` shape). @@ -125,9 +113,9 @@ function systemInstructionsAttribute(parts: GenAiMessagePart[]): string | undefi export function getConverseInputAttributes(input: Record): Record { const attributes: Record = {}; - const systemInstructions = systemInstructionsAttribute(contentBlocksToParts(input.system)); - if (systemInstructions) { - attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = systemInstructions; + const systemInstructions = contentBlocksToParts(input.system); + if (systemInstructions.length) { + attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = JSON.stringify(systemInstructions); } const messages = converseMessagesToMessages(input.messages); @@ -161,7 +149,7 @@ export function getClaudeInputAttributes(body: Record): Record< return attributes; } -/** Input attributes of the single-prompt `InvokeModel` bodies (Titan, Llama, Cohere, Mistral). */ +/** Input attributes of the single-prompt `InvokeModel` bodies (Titan, Llama, Cohere, Mistral, ...). */ export function getPromptInputAttributes(prompt: unknown): Record { if (typeof prompt !== 'string') { return {}; diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts index 3acf7f799aa0..88c709622e0b 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime.ts @@ -412,14 +412,10 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension { } function getInvokeModelInputAttributes(modelId: string, requestBody: ParsedChunk): Record { - if (modelId.includes('amazon.titan')) return getPromptInputAttributes(requestBody.inputText); if (modelId.includes('amazon.nova')) return getConverseInputAttributes(requestBody); if (modelId.includes('anthropic.claude')) return getClaudeInputAttributes(requestBody); - if (modelId.includes('meta.llama')) return getPromptInputAttributes(requestBody.prompt); - if (modelId.includes('cohere.command-r')) return getPromptInputAttributes(requestBody.message); - if (modelId.includes('cohere.command')) return getPromptInputAttributes(requestBody.prompt); - if (modelId.includes('mistral')) return getPromptInputAttributes(requestBody.prompt); - return {}; + // The other families send one prompt string: Titan as `inputText`, Cohere Command R as `message`. + return getPromptInputAttributes(requestBody.inputText ?? requestBody.message ?? requestBody.prompt); } type StreamProcessor = (parsedChunk: ParsedChunk, state: StreamResponseState) => void; From d3e57bb66722bafccfb853d0abc8a0558a63ea5d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 11 Nov 2026 11:28:19 +0100 Subject: [PATCH 5/6] Test Bedrock input recording against the real AWS SDK --- .../bedrock/instrument-gen-ai.mjs | 9 ++++++ .../aws-serverless/bedrock/scenario.mjs | 5 ++++ .../suites/aws-serverless/bedrock/test.ts | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument-gen-ai.mjs diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument-gen-ai.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument-gen-ai.mjs new file mode 100644 index 000000000000..fe1c5c47983f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument-gen-ai.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs index c1705ff0d403..14f377347a47 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs @@ -33,7 +33,11 @@ async function converse() { await client.send( new ConverseCommand({ modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + system: [{ text: 'You are a weather bot.' }], messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + toolConfig: { + tools: [{ toolSpec: { name: 'get_weather', inputSchema: { json: { type: 'object' } } } }], + }, inferenceConfig: { maxTokens: 100, temperature: 0.5, topP: 0.9 }, }), ); @@ -68,6 +72,7 @@ async function invokeModel() { max_tokens: 100, temperature: 0.5, top_p: 0.9, + system: 'You are a weather bot.', messages: [{ role: 'user', content: 'Hello' }], }), }), diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts index c028dbc686d7..dd9867391034 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -1,4 +1,5 @@ import { + GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MAX_TOKENS, @@ -7,6 +8,8 @@ import { GEN_AI_REQUEST_TOP_P, GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_STREAMING, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, SENTRY_OP, @@ -54,6 +57,25 @@ function assertBedrockSpans(container: SerializedStreamedSpanContainer): void { expect(invokeModelSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS].value).toBe('["end_turn"]'); } +const SYSTEM_INSTRUCTIONS = JSON.stringify([{ type: 'text', content: 'You are a weather bot.' }]); + +function assertBedrockInputs(container: SerializedStreamedSpanContainer): void { + const converseSpan = container.items.find(span => span.name === `chat ${MODEL_ID}`); + expect(converseSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(SYSTEM_INSTRUCTIONS); + expect(converseSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'Hello' }] }]), + ); + expect(converseSpan!.attributes[GEN_AI_TOOL_DEFINITIONS].value).toBe( + JSON.stringify([{ name: 'get_weather', inputSchema: { json: { type: 'object' } } }]), + ); + + const invokeModelSpan = container.items.find(span => span.name === `generate_content ${MODEL_ID}`); + expect(invokeModelSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe(SYSTEM_INSTRUCTIONS); + expect(invokeModelSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( + JSON.stringify([{ role: 'user', content: 'Hello' }]), + ); +} + function assertStreamSpans(container: SerializedStreamedSpanContainer): void { const converseStreamSpan = container.items.find(span => span.name === `chat ${MODEL_ID}`); expect(converseStreamSpan!.status).toBe('ok'); @@ -90,6 +112,12 @@ describe('awsIntegration - Bedrock', () => { }); }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-gen-ai.mjs', (createTestRunner, test) => { + test('records Bedrock inputs by default', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ span: assertBedrockInputs }).start().completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument.mjs', (createTestRunner, test) => { test('ends Bedrock stream spans when the streams finish, fail, or are aborted', { timeout: 90_000 }, async () => { await createTestRunner().ignore('event').expect({ span: assertStreamSpans }).start().completed(); From 549da26be85c7bc4af37d285072eda962116741a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 11 Nov 2026 15:14:20 +0100 Subject: [PATCH 6/6] Correct the Converse content block JSDoc --- .../integrations/aws-sdk/services/bedrock-runtime-content.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts index 53fe36a3a0f6..b38068553f21 100644 --- a/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts +++ b/packages/server-utils/src/integrations/aws-sdk/services/bedrock-runtime-content.ts @@ -9,7 +9,7 @@ import type { GenAiMessagePart } from '../../../ai/types'; /** * Translate Converse `ContentBlock`s (also used by Amazon Nova `InvokeModel` bodies) into message parts. - * Bedrock's blocks (`{ text }`, `{ toolUse }`, ...) carry no `type`, and Sentry drops parts it cannot type. + * The conventions require a `type` on each part, and Bedrock's blocks (`{ text }`, `{ toolUse }`, ...) have none. * * Media is reported by modality only: inline media is raw bytes, which the conventions require to be * dropped rather than recorded. Blocks without content worth recording (`cachePoint`, redacted