Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
);
Expand Down Expand Up @@ -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' }],
}),
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MAX_TOKENS,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions packages/server-utils/src/ai/anthropic-ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,7 +80,7 @@ export function extractRequestAttributes(
*/
export function addPrivateRequestAttributes(span: Span, params: Record<string, unknown>): void {
const messages = messagesFromParams(params);
setMessagesAttribute(span, messages);
span.setAttributes(getMessagesAttributes(messages));
}

/**
Expand Down
17 changes: 7 additions & 10 deletions packages/server-utils/src/ai/anthropic-ai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> {
if (Array.isArray(messages) && messages.length === 0) {
return;
return {};
}

const { systemInstructions, filteredMessages } = extractSystemInstructions(messages);

const attributes: Record<string, string | undefined> = {};
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<string, SpanStatusType> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { isObjectLike, stringify } from '@sentry/core';
import {
GEN_AI_INPUT_MESSAGES,
GEN_AI_SYSTEM_INSTRUCTIONS,
GEN_AI_TOOL_DEFINITIONS,
} from '@sentry/conventions/attributes';
import { getMessagesAttributes, messagesFromParams } from '../../../ai/anthropic-ai/utils';
import type { GenAiMessagePart } from '../../../ai/types';

/**
* Translate Converse `ContentBlock`s (also used by Amazon Nova `InvokeModel` bodies) into message parts.
* 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
* reasoning, unknown members) are left out rather than serialized, so no binary payload slips through.
*/
function contentBlocksToParts(content: unknown): GenAiMessagePart[] {
return Array.isArray(content) ? content.flatMap(contentBlockToParts) : [];
}

function contentBlockToParts(block: unknown): GenAiMessagePart[] {
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)) {
// `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);
}

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

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: GenAiMessagePart[] }> {
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);
}

/**
* 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<string, unknown>): Record<string, unknown> {
const attributes: Record<string, unknown> = {};

const systemInstructions = contentBlocksToParts(input.system);
if (systemInstructions.length) {
attributes[GEN_AI_SYSTEM_INSTRUCTIONS] = JSON.stringify(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;
}

/**
* Claude `InvokeModel` bodies are Anthropic Messages API requests, so they are recorded like the Anthropic
* integration records them.
*/
export function getClaudeInputAttributes(body: Record<string, unknown>): Record<string, unknown> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this one handle inline media? So we don't attach raw base64 to gen_ai.input.messages?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it doesn't, which is in line with the anthropic integration. A while ago we decided to walk back on truncation and stripping images out. This has since changed, but we need to align this on all integrations.

Webby and I are working on aligning integrations more in the future, but we are at the stage of writing clearer specs.

const attributes = getMessagesAttributes(messagesFromParams(body));
if (Array.isArray(body.tools)) {
attributes[GEN_AI_TOOL_DEFINITIONS] = JSON.stringify(body.tools);
}
return attributes;
}

/** Input attributes of the single-prompt `InvokeModel` bodies (Titan, Llama, Cohere, Mistral, ...). */
export function getPromptInputAttributes(prompt: unknown): Record<string, unknown> {
if (typeof prompt !== 'string') {
return {};
}
return { [GEN_AI_INPUT_MESSAGES]: JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: prompt }] }]) };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 { isInsidePiAiSpan } from '../../../ai/pi-ai/providers';
import { DEBUG_BUILD } from '../../../debug-build';
import {
Expand All @@ -23,6 +23,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
Expand Down Expand Up @@ -134,6 +139,10 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension {
}
}

if (resolveAIRecordingOptions().recordInputs) {
Object.assign(spanAttributes, getConverseInputAttributes(request.commandInput));
}

return {
spanName,
spanOp: GEN_AI_CHAT,
Expand Down Expand Up @@ -258,6 +267,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 {
Expand Down Expand Up @@ -412,6 +425,13 @@ export class BedrockRuntimeServiceExtension implements ServiceExtension {
}
}

function getInvokeModelInputAttributes(modelId: string, requestBody: ParsedChunk): Record<string, unknown> {
if (modelId.includes('amazon.nova')) return getConverseInputAttributes(requestBody);
if (modelId.includes('anthropic.claude')) return getClaudeInputAttributes(requestBody);
// 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;

function resolveStreamProcessor(modelId: string): StreamProcessor | undefined {
Expand Down
28 changes: 5 additions & 23 deletions packages/server-utils/test/ai/lib/utils/anthropic-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -98,32 +97,15 @@ describe('anthropic-ai-utils', () => {
});
});

describe('setMessagesAtribute', () => {
const mock = {
attributes: {} as Record<string, any>,
setAttributes(kv: Record<string, any>) {
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({});
});
});
});
Loading
Loading