From b345ff6304171d73e084ab33fcbc692c0b1e3c86 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 5 Aug 2026 10:23:47 +0200 Subject: [PATCH 1/8] feat(core)!: Remove `enableTruncation` flag and gen_ai text truncation Remove genai text truncation: - Removes the `enableTruncation` integration option and all gen_ai text-truncation logic (`messageTruncation.ts`); byte-limit cropping and "keep only the last message" are gone now that gen_ai spans always use the v2 span path. - Keeps inline media stripping and makes it unconditional, so this is now being applied to both transaction and span streaming paths. - All input messages are now recorded in full (media stripped), stripping is consolidated into `mediaStripping.ts`. - Covers both `@sentry/core` and `@sentry/server-utils`. - Removes truncation specific tests. Adds a new span streaming scenario because the span streaming path was so far only tested together with truncation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anthropic/instrument-no-truncation.mjs | 25 -- .../instrument-streaming-with-truncation.mjs | 16 - .../anthropic/instrument-with-truncation.mjs | 19 - ...ation.mjs => scenario-media-stripping.mjs} | 1 - .../anthropic/scenario-message-truncation.mjs | 75 ---- .../anthropic/scenario-no-truncation.mjs | 64 --- .../suites/tracing/anthropic/test.ts | 226 +++------- .../google-genai/instrument-no-truncation.mjs | 25 -- .../instrument-streaming-with-truncation.mjs | 16 - .../instrument-with-truncation.mjs | 19 - .../scenario-message-truncation.mjs | 81 ---- .../google-genai/scenario-no-truncation.mjs | 55 --- .../google-genai/scenario-span-streaming.mjs | 1 - .../suites/tracing/google-genai/test.ts | 120 +---- .../langchain/instrument-no-truncation.mjs | 25 -- .../instrument-streaming-with-truncation.mjs | 16 - .../langchain/instrument-with-truncation.mjs | 19 - .../langchain/scenario-message-truncation.mjs | 82 ---- .../langchain/scenario-no-truncation.mjs | 56 --- .../langchain/scenario-span-streaming.mjs | 1 - .../suites/tracing/langchain/test.ts | 122 +----- .../v1/instrument-with-truncation.mjs | 19 - .../v1/scenario-message-truncation.mjs | 82 ---- .../suites/tracing/langchain/v1/test.ts | 63 +-- .../langgraph/instrument-no-truncation.mjs | 18 - .../instrument-streaming-with-truncation.mjs | 16 - .../langgraph/scenario-no-truncation.mjs | 46 -- .../langgraph/scenario-span-streaming.mjs | 1 - .../suites/tracing/langgraph/test.ts | 74 +--- .../openai/instrument-no-truncation.mjs | 24 - .../instrument-streaming-with-truncation.mjs | 16 - .../openai/instrument-with-truncation.mjs | 18 - .../tracing/openai/scenario-no-truncation.mjs | 87 ---- .../suites/tracing/openai/test.ts | 247 +---------- ...cenario-message-truncation-completions.mjs | 80 ---- .../scenario-message-truncation-responses.mjs | 92 ---- .../vercelai/instrument-no-truncation.mjs | 18 - .../vercelai/instrument-with-truncation.mjs | 12 - .../vercelai/scenario-message-truncation.mjs | 51 --- .../vercelai/scenario-no-truncation.mjs | 28 -- .../instrument-with-truncation.mjs | 16 - .../span-streaming-v4/scenario-truncation.mjs | 27 -- .../vercelai/span-streaming-v4/test.ts | 46 +- .../suites/tracing/vercelai/test.ts | 93 ---- .../server-utils/src/ai/anthropic-ai/index.ts | 21 +- .../server-utils/src/ai/anthropic-ai/types.ts | 5 - .../server-utils/src/ai/anthropic-ai/utils.ts | 8 +- .../src/ai/core/mediaStripping.ts | 75 ++++ .../src/ai/core/messageTruncation.ts | 409 ------------------ packages/server-utils/src/ai/core/utils.ts | 36 +- .../server-utils/src/ai/google-genai/index.ts | 24 +- .../server-utils/src/ai/google-genai/types.ts | 5 - .../server-utils/src/ai/langchain/index.ts | 5 +- .../server-utils/src/ai/langchain/types.ts | 6 - .../server-utils/src/ai/langchain/utils.ts | 16 +- .../server-utils/src/ai/langgraph/index.ts | 12 +- .../server-utils/src/ai/langgraph/types.ts | 5 - packages/server-utils/src/ai/openai/index.ts | 20 +- packages/server-utils/src/ai/openai/types.ts | 5 - .../server-utils/src/ai/vercel-ai/index.ts | 13 +- .../server-utils/src/ai/vercel-ai/utils.ts | 18 +- .../server-utils/src/ai/workers-ai/index.ts | 6 +- .../server-utils/src/ai/workers-ai/types.ts | 8 +- .../server-utils/src/ai/workers-ai/utils.ts | 15 +- .../integrations/tracing-channel/anthropic.ts | 5 +- .../tracing-channel/google-genai.ts | 5 +- .../integrations/tracing-channel/openai.ts | 5 +- packages/server-utils/src/vercel-ai/index.ts | 6 - .../src/vercel-ai/vercel-ai-dc-subscriber.ts | 24 +- ...ion.test.ts => ai-media-stripping.test.ts} | 284 +++--------- .../test/ai/lib/tracing/ai/utils.test.ts | 51 +-- .../ai/lib/tracing/langchain-utils.test.ts | 2 +- .../vercel-ai-request-messages.test.ts | 47 +- .../test/ai/lib/utils/anthropic-utils.test.ts | 13 +- .../src/integrations/tracing/vercelai.ts | 6 - 75 files changed, 304 insertions(+), 3094 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-truncation.mjs rename dev-packages/node-integration-tests/suites/tracing/anthropic/{scenario-media-truncation.mjs => scenario-media-stripping.mjs} (96%) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-message-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-message-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs delete mode 100644 packages/server-utils/src/ai/core/messageTruncation.ts rename packages/server-utils/test/ai/lib/tracing/{ai-message-truncation.test.ts => ai-media-stripping.test.ts} (56%) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs deleted file mode 100644 index 5d4afd8e6fed..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: false, outputs: false } }, - transport: loggingTransport, - integrations: [ - Sentry.anthropicAIIntegration({ - recordInputs: true, - recordOutputs: true, - enableTruncation: false, - }), - ], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/anthropic/v1/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs deleted file mode 100644 index 048f3de408e2..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.anthropicAIIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-truncation.mjs deleted file mode 100644 index f4330e30592f..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-truncation.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.anthropicAIIntegration({ enableTruncation: true })], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/anthropic/v1/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs similarity index 96% rename from dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs rename to dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs index 48f337e2b23c..92f609875dc8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs @@ -36,7 +36,6 @@ async function run() { }); // Send the image showing the number 3 - // Put the image in the last message so it doesn't get dropped await client.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 1024, diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs deleted file mode 100644 index 27aa8494f693..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/anthropic/v1/messages', (req, res) => { - res.send({ - id: 'msg-truncation-test', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'Response to truncated messages' }], - model: req.body.model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 15 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new Anthropic({ - apiKey: 'mock-api-key', - baseURL: `http://localhost:${server.address().port}/anthropic`, - }); - - // Test 1: Given an array of messages only the last message should be kept - // The last message should be truncated to fit within the 20KB limit - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated, only C's remain) - - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 100, - messages: [ - { role: 'user', content: largeContent1 }, - { role: 'assistant', content: largeContent2 }, - { role: 'user', content: largeContent3 }, - ], - temperature: 0.7, - }); - - // Test 2: Given an array of messages only the last message should be kept - // The last message is small, so it should be kept intact - const smallContent = 'This is a small message that fits within the limit'; - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 100, - messages: [ - { role: 'user', content: largeContent1 }, - { role: 'assistant', content: largeContent2 }, - { role: 'user', content: smallContent }, - ], - temperature: 0.7, - }); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs deleted file mode 100644 index f66cee978733..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/anthropic/v1/messages', (req, res) => { - res.send({ - id: 'msg-no-truncation-test', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'Response' }], - model: req.body.model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new Anthropic({ - apiKey: 'mock-api-key', - baseURL: `http://localhost:${server.address().port}/anthropic`, - }); - - // Multiple messages with long content (would normally be truncated and popped to last message only) - const longContent = 'A'.repeat(50_000); - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 100, - messages: [ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ], - }); - - // Long string input (messagesFromParams wraps it in an array) - const longStringInput = 'B'.repeat(50_000); - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 100, - input: longStringInput, - }); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index ebd66be60989..9e754accfaad 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -592,107 +592,54 @@ describe('Anthropic integration', () => { }); }); - createEsmAndCjsTests( - __dirname, - 'scenario-message-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const smallMsgValue = JSON.stringify([ - { role: 'user', content: 'This is a small message that fits within the limit' }, - ]); - const truncatedSpan = container.items.find(span => - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match( - /^\[\{"role":"user","content":"C+"\}\]$/, - ), - ); - expect(truncatedSpan).toBeDefined(); - expect(truncatedSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(truncatedSpan!.status).toBe('ok'); - expect(truncatedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); - expect(truncatedSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); - expect(truncatedSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); - expect(truncatedSpan!.attributes[GEN_AI_SYSTEM].value).toBe('anthropic'); - expect(truncatedSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); - - const smallMessageSpan = container.items.find( - span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === smallMsgValue, - ); - expect(smallMessageSpan).toBeDefined(); - expect(smallMessageSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(smallMessageSpan!.status).toBe('ok'); - expect(smallMessageSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); - expect(smallMessageSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); - expect(smallMessageSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); - expect(smallMessageSpan!.attributes[GEN_AI_SYSTEM].value).toBe('anthropic'); - expect(smallMessageSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); - }, - }) - .start() - .completed(); - }); - }, - ); - - createEsmAndCjsTests( - __dirname, - 'scenario-media-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates media attachment, keeping all other details', async () => { - const expectedMediaMessages = JSON.stringify([ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: '[Blob substitute]', - }, + createEsmAndCjsTests(__dirname, 'scenario-media-stripping.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('strips media attachment, keeping all other messages and details', async () => { + const expectedMediaMessages = JSON.stringify([ + { + role: 'user', + content: 'what number is this?', + }, + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: '[Blob substitute]', }, - ], - }, - ]); - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; + ], + }, + ]); + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(1); + const [firstSpan] = container.items; - // [0] messages.create with media attachment — image data replaced, other fields preserved - expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(expectedMediaMessages); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); - expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); - expect(firstSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); - expect(firstSpan!.attributes[GEN_AI_SYSTEM].value).toBe('anthropic'); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); - }, - }) - .start() - .completed(); - }); - }, - ); + // messages.create with media attachment — image data replaced, all other messages/fields preserved + expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307'); + expect(firstSpan!.status).toBe('ok'); + expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(expectedMediaMessages); + expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); + expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); + expect(firstSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); + expect(firstSpan!.attributes[GEN_AI_SYSTEM].value).toBe('anthropic'); + expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); + }, + }) + .start() + .completed(); + }); + }); createEsmAndCjsTests( __dirname, @@ -723,59 +670,14 @@ describe('Anthropic integration', () => { }, ); - const longContent = 'A'.repeat(50_000); - const longStringInput = 'B'.repeat(50_000); - - const EXPECTED_TRANSACTION_NO_TRUNCATION = { - transaction: 'main', - }; - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - const expectedAllMessages = JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]); - const expectedLongString = JSON.stringify([longStringInput]); - await createRunner() - .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const conversationSpan = container.items.find( - span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === expectedAllMessages, - ); - expect(conversationSpan).toBeDefined(); - - const longStringSpan = container.items.find( - span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === expectedLongString, - ); - expect(longStringSpan).toBeDefined(); - }, - }) - .start() - .completed(); - }); - }, - ); - - const streamingLongContent = 'A'.repeat(50_000); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { + test('records full gen_ai input messages when span streaming is enabled', async () => { + const longContent = 'A'.repeat(50_000); await createRunner() .expect({ span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), + const chatSpan = container.items.find(s => + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), ); expect(chatSpan).toBeDefined(); }, @@ -784,34 +686,4 @@ describe('Anthropic integration', () => { .completed(); }); }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","content":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs deleted file mode 100644 index 7dc07bc2e077..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: false, outputs: false } }, - transport: loggingTransport, - integrations: [ - Sentry.googleGenAIIntegration({ - recordInputs: true, - recordOutputs: true, - enableTruncation: false, - }), - ], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/v1beta/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs deleted file mode 100644 index 0e148493344a..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.googleGenAIIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-truncation.mjs deleted file mode 100644 index 67f83d77e7a4..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-truncation.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.googleGenAIIntegration({ enableTruncation: true })], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/v1beta/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs deleted file mode 100644 index d3cb34648f4a..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import { GoogleGenAI } from '@google/genai'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockGoogleGenAIServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/v1beta/models/:model\\:generateContent', (req, res) => { - res.send({ - candidates: [ - { - content: { parts: [{ text: 'Response to truncated messages' }], role: 'model' }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 15, totalTokenCount: 25 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockGoogleGenAIServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new GoogleGenAI({ - apiKey: 'mock-api-key', - httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, - }); - - // Test 1: Given an array of messages only the last message should be kept - // The last message should be truncated to fit within the 20KB limit - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated, only C's remain) - - await client.models.generateContent({ - model: 'gemini-1.5-flash', - config: { - temperature: 0.7, - topP: 0.9, - maxOutputTokens: 100, - }, - contents: [ - { role: 'user', parts: [{ text: largeContent1 }] }, - { role: 'model', parts: [{ text: largeContent2 }] }, - { role: 'user', parts: [{ text: largeContent3 }] }, - ], - }); - - // Test 2: Given an array of messages only the last message should be kept - // The last message is small, so it should be kept intact - const smallContent = 'This is a small message that fits within the limit'; - await client.models.generateContent({ - model: 'gemini-1.5-flash', - config: { - temperature: 0.7, - topP: 0.9, - maxOutputTokens: 100, - }, - contents: [ - { role: 'user', parts: [{ text: largeContent1 }] }, - { role: 'model', parts: [{ text: largeContent2 }] }, - { role: 'user', parts: [{ text: smallContent }] }, - ], - }); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs deleted file mode 100644 index 67ece6759577..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import { GoogleGenAI } from '@google/genai'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockGoogleGenAIServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/v1beta/models/:model\\:generateContent', (req, res) => { - res.send({ - candidates: [ - { - content: { parts: [{ text: 'Response' }], role: 'model' }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockGoogleGenAIServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new GoogleGenAI({ - apiKey: 'mock-api-key', - httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, - }); - - // Long content that would normally be truncated - const longContent = 'A'.repeat(50_000); - await client.models.generateContent({ - model: 'gemini-1.5-flash', - contents: [ - { role: 'user', parts: [{ text: longContent }] }, - { role: 'model', parts: [{ text: 'Some reply' }] }, - { role: 'user', parts: [{ text: 'Follow-up question' }] }, - ], - }); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs index f5b2656b5cb0..5785cd07d9a0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs @@ -34,7 +34,6 @@ async function run() { httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, }); - // Long content that would normally be truncated const longContent = 'A'.repeat(50_000); await client.models.generateContent({ model: 'gemini-1.5-flash', diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 9f61c8a127e4..0e96756072f1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -344,50 +344,6 @@ describe('Google GenAI integration', () => { }); }); - createEsmAndCjsTests( - __dirname, - 'scenario-message-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const truncatedSpan = container.items.find(span => - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match( - /^\[\{"role":"user","parts":\[\{"text":"C+"\}\]\}\]$/, - ), - ); - expect(truncatedSpan).toBeDefined(); - expect(truncatedSpan!.name).toBe('generate_content gemini-1.5-flash'); - expect(truncatedSpan!.status).toBe('ok'); - expect(truncatedSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); - - const smallMessageSpan = container.items.find( - span => - span.attributes[GEN_AI_INPUT_MESSAGES]?.value === - JSON.stringify([ - { - role: 'user', - parts: [{ text: 'This is a small message that fits within the limit' }], - }, - ]), - ); - expect(smallMessageSpan).toBeDefined(); - expect(smallMessageSpan!.name).toBe('generate_content gemini-1.5-flash'); - expect(smallMessageSpan!.status).toBe('ok'); - expect(smallMessageSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); - }, - }) - .start() - .completed(); - }); - }, - ); - createEsmAndCjsTests( __dirname, 'scenario-system-instructions.mjs', @@ -502,86 +458,20 @@ describe('Google GenAI integration', () => { }); }); - const longContent = 'A'.repeat(50_000); - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - - // [0] generate_content with full (non-truncated) input messages - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( - JSON.stringify([ - { role: 'user', parts: [{ text: longContent }] }, - { role: 'model', parts: [{ text: 'Some reply' }] }, - { role: 'user', parts: [{ text: 'Follow-up question' }] }, - ]), - ); - }, - }) - .start() - .completed(); - }); - }, - ); - - const streamingLongContent = 'A'.repeat(50_000); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { + test('records full gen_ai input messages when span streaming is enabled', async () => { + const longContent = 'A'.repeat(50_000); await createRunner() .expect({ span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), + const generateContentSpan = container.items.find(s => + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), ); - expect(chatSpan).toBeDefined(); + expect(generateContentSpan).toBeDefined(); }, }) .start() .completed(); }); }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","parts":[{"text":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs deleted file mode 100644 index d119052c6120..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [ - Sentry.langChainIntegration({ - enableTruncation: false, - recordInputs: true, - recordOutputs: true, - }), - ], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs deleted file mode 100644 index 2a2365407b79..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.langChainIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-truncation.mjs deleted file mode 100644 index 8ba041d37cf6..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-truncation.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.langChainIntegration({ enableTruncation: true })], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs deleted file mode 100644 index 9e5e59f264ca..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-message-truncation.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { ChatAnthropic } from '@langchain/anthropic'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json()); - - app.post('/v1/messages', (req, res) => { - const model = req.body.model; - - res.json({ - id: 'msg_truncation_test', - type: 'message', - role: 'assistant', - content: [ - { - type: 'text', - text: 'Response to truncated messages', - }, - ], - model: model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 15, - }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - const baseUrl = `http://localhost:${server.address().port}`; - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const model = new ChatAnthropic({ - model: 'claude-3-5-sonnet-20241022', - apiKey: 'mock-api-key', - clientOptions: { - baseURL: baseUrl, - }, - }); - - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated, only C's remain) - - // Test 1: Create one very large string that gets truncated to only include Cs - await model.invoke(largeContent3); - - // Test 2: Create an array of messages that gets truncated to only include the last message - // The last message should be truncated to fit within the 20KB limit (result should again contain only Cs) - await model.invoke([ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: largeContent3 }, - ]); - - // Test 3: Given an array of messages only the last message should be kept - // The last message is small, so it should be kept intact - const smallContent = 'This is a small message that fits within the limit'; - await model.invoke([ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: smallContent }, - ]); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs deleted file mode 100644 index bb8f5fc35325..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { ChatAnthropic } from '@langchain/anthropic'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/v1/messages', (req, res) => { - res.json({ - id: 'msg_no_truncation_test', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'Response' }], - model: req.body.model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - const baseUrl = `http://localhost:${server.address().port}`; - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const model = new ChatAnthropic({ - model: 'claude-3-5-sonnet-20241022', - apiKey: 'mock-api-key', - clientOptions: { - baseURL: baseUrl, - }, - }); - - // Long content that would normally be truncated - const longContent = 'A'.repeat(50_000); - await model.invoke([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs index 0d049d346e98..e80d0c292e0c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs @@ -39,7 +39,6 @@ async function run() { }, }); - // Single long message so truncation must crop it const longContent = 'A'.repeat(50_000); await model.invoke([{ role: 'user', content: longContent }]); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 32d30cdef4e9..2ff64fdbfd89 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -199,55 +199,6 @@ describe('LangChain integration', () => { }); }); - createEsmAndCjsTests( - __dirname, - 'scenario-message-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(3); - // The string-input span has no system message (and therefore no system instructions), - // while the array-input span does — use that to distinguish the two truncated spans. - const truncatedContent = /^\[\{"role":"user","content":"C+"\}\]$/; - const stringInputSpan = container.items.find( - span => - span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS] === undefined && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match(truncatedContent), - ); - expect(stringInputSpan).toBeDefined(); - expect(stringInputSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(stringInputSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toMatch(truncatedContent); - - const arrayInputSpan = container.items.find( - span => - span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS] !== undefined && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match(truncatedContent), - ); - expect(arrayInputSpan).toBeDefined(); - expect(arrayInputSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(arrayInputSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeDefined(); - - const smallMessageSpan = container.items.find( - span => - span.attributes[GEN_AI_INPUT_MESSAGES]?.value === - JSON.stringify([{ role: 'user', content: 'This is a small message that fits within the limit' }]), - ); - expect(smallMessageSpan).toBeDefined(); - expect(smallMessageSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(smallMessageSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeDefined(); - }, - }) - .start() - .completed(); - }); - }, - ); - createEsmTests(__dirname, 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { test('demonstrates timing issue with duplicate spans', async () => { await createRunner() @@ -450,50 +401,14 @@ describe('LangChain integration', () => { }); }); - const longContent = 'A'.repeat(50_000); - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - - // [0] chat with full (untruncated) input messages - expect(firstSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( - JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]), - ); - }, - }) - .start() - .completed(); - }); - }, - ); - - const streamingLongContent = 'A'.repeat(50_000); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { + test('records full gen_ai input messages when span streaming is enabled', async () => { + const longContent = 'A'.repeat(50_000); await createRunner() .expect({ span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), + const chatSpan = container.items.find(s => + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), ); expect(chatSpan).toBeDefined(); }, @@ -502,33 +417,4 @@ describe('LangChain integration', () => { .completed(); }); }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","content":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-truncation.mjs deleted file mode 100644 index 7ba8b1acd686..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-truncation.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.langChainIntegration({ enableTruncation: true })], - beforeSendTransaction: event => { - // Filter out mock express server transactions - if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/chat/completions')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-message-truncation.mjs deleted file mode 100644 index 9e5e59f264ca..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-message-truncation.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { ChatAnthropic } from '@langchain/anthropic'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json()); - - app.post('/v1/messages', (req, res) => { - const model = req.body.model; - - res.json({ - id: 'msg_truncation_test', - type: 'message', - role: 'assistant', - content: [ - { - type: 'text', - text: 'Response to truncated messages', - }, - ], - model: model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 15, - }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - const baseUrl = `http://localhost:${server.address().port}`; - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const model = new ChatAnthropic({ - model: 'claude-3-5-sonnet-20241022', - apiKey: 'mock-api-key', - clientOptions: { - baseURL: baseUrl, - }, - }); - - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated, only C's remain) - - // Test 1: Create one very large string that gets truncated to only include Cs - await model.invoke(largeContent3); - - // Test 2: Create an array of messages that gets truncated to only include the last message - // The last message should be truncated to fit within the 20KB limit (result should again contain only Cs) - await model.invoke([ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: largeContent3 }, - ]); - - // Test 3: Given an array of messages only the last message should be kept - // The last message is small, so it should be kept intact - const smallContent = 'This is a small message that fits within the limit'; - await model.invoke([ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: smallContent }, - ]); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index 9a9a7a8f75b6..dd53cd059753 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -11,13 +11,12 @@ import { GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM, - GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; -import { conditionalTest, getStringAttributeValue } from '../../../../utils'; +import { conditionalTest } from '../../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests'; @@ -213,66 +212,6 @@ conditionalTest({ min: 20 })('LangChain integration (v1)', () => { }, ); - createEsmAndCjsTests( - __dirname, - 'scenario-message-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(3); - // The string-input span has no system message (and therefore no system instructions), - // while the array-input span does — use that to distinguish the two truncated spans. - const truncatedContent = /^\[\{"role":"user","content":"C+"\}\]$/; - const stringInputSpan = container.items.find( - span => - span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS] === undefined && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match(truncatedContent), - ); - expect(stringInputSpan).toBeDefined(); - expect(stringInputSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(stringInputSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toMatch(truncatedContent); - - const arrayInputSpan = container.items.find( - span => - span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS] !== undefined && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match(truncatedContent), - ); - expect(arrayInputSpan).toBeDefined(); - expect(arrayInputSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(arrayInputSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toMatch( - /^\[\{"type":"text","content":"A+"\}\]$/, - ); - - const smallMessageSpan = container.items.find( - span => - span.attributes[GEN_AI_INPUT_MESSAGES]?.value === - JSON.stringify([{ role: 'user', content: 'This is a small message that fits within the limit' }]), - ); - expect(smallMessageSpan).toBeDefined(); - expect(smallMessageSpan!.name).toBe('chat claude-3-5-sonnet-20241022'); - expect(smallMessageSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toMatch( - /^\[\{"type":"text","content":"A+"\}\]$/, - ); - }, - }) - .start() - .completed(); - }); - }, - { - additionalDependencies: { - langchain: '^1.0.0', - '@langchain/core': '^1.0.0', - '@langchain/anthropic': '^1.0.0', - }, - }, - ); - createEsmTests( __dirname, 'scenario-openai-before-langchain.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs deleted file mode 100644 index 9887a7c88c73..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [ - Sentry.langGraphIntegration({ - recordInputs: true, - recordOutputs: true, - enableTruncation: false, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs deleted file mode 100644 index c76ba5928e20..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.langGraphIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs deleted file mode 100644 index 982e7a69de53..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; -import * as Sentry from '@sentry/node'; - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'langgraph-test' }, async () => { - const mockLlm = () => { - return { - messages: [ - { - role: 'assistant', - content: 'Mock LLM response', - response_metadata: { - model_name: 'mock-model', - finish_reason: 'stop', - tokenUsage: { - promptTokens: 20, - completionTokens: 10, - totalTokens: 30, - }, - }, - }, - ], - }; - }; - - const graph = new StateGraph(MessagesAnnotation) - .addNode('agent', mockLlm) - .addEdge(START, 'agent') - .addEdge('agent', END) - .compile({ name: 'weather_assistant' }); - - // Multiple messages with long content (would normally be truncated and popped to last message only) - const longContent = 'A'.repeat(50_000); - await graph.invoke({ - messages: [ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ], - }); - }); - - await Sentry.flush(2000); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs index bfba2d1fcd7f..fe5ff23c10aa 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs @@ -29,7 +29,6 @@ async function run() { .addEdge('agent', END) .compile({ name: 'weather_assistant' }); - // Single long message so truncation must crop it const longContent = 'A'.repeat(50_000); await graph.invoke({ messages: [{ role: 'user', content: longContent }], diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 91481ae3d82c..68775ef68fdd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -235,51 +235,14 @@ describe('LangGraph integration', () => { }); }); - const longContent = 'A'.repeat(50_000); - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'langgraph-test' } }) - .expect({ - span: container => { - const expectedMessages = JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]); - - expect(container.items).toHaveLength(1); - const invokeAgentSpan = container.items.find( - span => span.attributes[GEN_AI_INPUT_MESSAGES]?.value === expectedMessages, - ); - - expect(invokeAgentSpan).toBeDefined(); - expect(invokeAgentSpan!.name).toBe('invoke_agent weather_assistant'); - }, - }) - .start() - .completed(); - }); - }, - ); - - const streamingLongContent = 'A'.repeat(50_000); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { + test('records full gen_ai input messages when span streaming is enabled', async () => { + const longContent = 'A'.repeat(50_000); await createRunner() .expect({ span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), + const chatSpan = container.items.find(s => + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), ); expect(chatSpan).toBeDefined(); }, @@ -289,35 +252,6 @@ describe('LangGraph integration', () => { }); }); - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","content":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - }, - }) - .start() - .completed(); - }); - }, - ); - // createReactAgent tests. // Spans are asserted order-independently: the span-array order is not a protocol guarantee (Sentry // rebuilds the tree from `parent_span_id`), and the provider emits tree order while the OTel exporter diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs deleted file mode 100644 index 6f77c39a09dc..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: false, outputs: false } }, - transport: loggingTransport, - integrations: [ - Sentry.openAIIntegration({ - recordInputs: true, - recordOutputs: true, - enableTruncation: false, - }), - ], - beforeSendTransaction: event => { - if (event.transaction.includes('/openai/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs deleted file mode 100644 index c61dffa4c1f1..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.openAIIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-truncation.mjs deleted file mode 100644 index 771f507deafd..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-truncation.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.openAIIntegration({ enableTruncation: true })], - beforeSendTransaction: event => { - if (event.transaction.includes('/openai/')) { - return null; - } - return event; - }, -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs deleted file mode 100644 index c5fe61c1ab66..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import OpenAI from 'openai'; - -function startMockServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/openai/chat/completions', (req, res) => { - res.send({ - id: 'chatcmpl-mock123', - object: 'chat.completion', - created: 1677652288, - model: req.body.model, - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Hello!' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }); - }); - - app.post('/openai/responses', (req, res) => { - res.send({ - id: 'resp_mock456', - object: 'response', - created_at: 1677652290, - model: req.body.model, - output: [ - { - type: 'message', - id: 'msg_mock_output_1', - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text: 'Response text', annotations: [] }], - }, - ], - output_text: 'Response text', - status: 'completed', - usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new OpenAI({ - baseURL: `http://localhost:${server.address().port}/openai`, - apiKey: 'mock-api-key', - }); - - // Multiple messages with long content (would normally be truncated and popped to last message only) - const longContent = 'A'.repeat(50_000); - await client.chat.completions.create({ - model: 'gpt-4', - messages: [ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ], - }); - - // Responses API with long string input (would normally be truncated) - const longStringInput = 'B'.repeat(50_000); - await client.responses.create({ - model: 'gpt-4', - input: longStringInput, - }); - }); - - // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits - await Sentry.flush(); - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 32ed89258908..98c47d82cf41 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -724,61 +724,6 @@ describe('OpenAI integration', () => { }); }); - const longContent = 'A'.repeat(50_000); - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const chatCompletionSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', - ); - expect(chatCompletionSpan).toBeDefined(); - expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ - type: 'string', - value: 'chatcmpl-mock123', - }); - expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({ - type: 'string', - value: JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]), - }); - - const responsesSpan = container.items.find( - span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'resp_mock456', - ); - expect(responsesSpan).toBeDefined(); - expect(responsesSpan!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ - type: 'string', - value: 'resp_mock456', - }); - expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toMatchObject({ - type: 'string', - value: 'B'.repeat(50_000), - }); - }, - }) - .start() - .completed(); - }); - }, - ); - createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording disabled', async () => { await createRunner() @@ -1141,146 +1086,6 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests( - __dirname, - 'truncation/scenario-message-truncation-completions.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const truncatedMessageSpan = container.items.find(span => - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match( - /^\[\{"role":"user","content":"C+"\}\]$/, - ), - ); - expect(truncatedMessageSpan).toBeDefined(); - expect(truncatedMessageSpan!.name).toBe('chat gpt-3.5-turbo'); - expect(truncatedMessageSpan!.status).toBe('ok'); - expect(truncatedMessageSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ - type: 'string', - value: 'chat', - }); - expect(truncatedMessageSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ - type: 'string', - value: 'gen_ai.chat', - }); - expect(truncatedMessageSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ - type: 'string', - value: 'auto.ai.openai', - }); - expect(truncatedMessageSpan!.attributes[GEN_AI_SYSTEM]).toEqual({ - type: 'string', - value: 'openai', - }); - expect(truncatedMessageSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ - type: 'string', - value: 'gpt-3.5-turbo', - }); - expect(truncatedMessageSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toMatch( - /^\[\{"role":"user","content":"C+"\}\]$/, - ); - expect(truncatedMessageSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toMatch( - /^\[\{"type":"text","content":"A+"\}\]$/, - ); - - const smallMessageSpan = container.items.find( - span => - span.attributes[GEN_AI_INPUT_MESSAGES]?.value === - JSON.stringify([{ role: 'user', content: 'This is a small message that fits within the limit' }]), - ); - expect(smallMessageSpan).toBeDefined(); - expect(smallMessageSpan!.name).toBe('chat gpt-3.5-turbo'); - expect(smallMessageSpan!.status).toBe('ok'); - expect(smallMessageSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ - type: 'string', - value: 'chat', - }); - expect(smallMessageSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ - type: 'string', - value: 'gen_ai.chat', - }); - expect(smallMessageSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ - type: 'string', - value: 'auto.ai.openai', - }); - expect(smallMessageSpan!.attributes[GEN_AI_SYSTEM]).toEqual({ - type: 'string', - value: 'openai', - }); - expect(smallMessageSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ - type: 'string', - value: 'gpt-3.5-turbo', - }); - expect(smallMessageSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ - type: 'string', - value: JSON.stringify([ - { role: 'user', content: 'This is a small message that fits within the limit' }, - ]), - }); - expect(smallMessageSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toMatch( - /^\[\{"type":"text","content":"A+"\}\]$/, - ); - }, - }) - .start() - .completed(); - }); - }, - ); - - createEsmAndCjsTests( - __dirname, - 'truncation/scenario-message-truncation-responses.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates string inputs when they exceed byte limit', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - - // [0] long A-string input is truncated - expect(firstSpan!.name).toBe('chat gpt-3.5-turbo'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' }); - expect(firstSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ - type: 'string', - value: 'gen_ai.chat', - }); - expect(firstSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ - type: 'string', - value: 'auto.ai.openai', - }); - expect(firstSpan!.attributes[GEN_AI_SYSTEM]).toEqual({ type: 'string', value: 'openai' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ - type: 'string', - value: 'gpt-3.5-turbo', - }); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toMatch(/^A+$/); - }, - }) - .start() - .completed(); - }); - }, - ); - // Test for conversation ID support (Conversations API and previous_response_id) createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { test('captures conversation ID from Conversations API and previous_response_id', async () => { @@ -1561,7 +1366,7 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('redacts inline base64 image data in vision requests', async () => { await createRunner() .ignore('event') @@ -1615,23 +1420,22 @@ describe('OpenAI integration', () => { }); }); - const streamingLongContent = 'A'.repeat(50_000); - const streamingLongString = 'B'.repeat(50_000); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { + test('records full gen_ai input messages when span streaming is enabled', async () => { + const longContent = 'A'.repeat(50_000); + const longStringInput = 'B'.repeat(50_000); await createRunner() .expect({ span: container => { const spans = container.items; const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), ); expect(chatSpan).toBeDefined(); const responsesSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongString), + getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longStringInput), ); expect(responsesSpan).toBeDefined(); }, @@ -1640,43 +1444,4 @@ describe('OpenAI integration', () => { .completed(); }); }); - - createEsmAndCjsTests( - __dirname, - 'scenario-span-streaming.mjs', - 'instrument-streaming-with-truncation.mjs', - (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - // Truncation keeps only the last message (50k 'A's) and crops it to the byte limit. - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","content":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - - // The responses API string input (50k 'B's) should also be truncated. - const responsesSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith('BBB'), - ); - expect(responsesSpan).toBeDefined(); - expect( - (getStringAttributeValue(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongString.length); - }, - }) - .start() - .completed(); - }); - }, - ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs deleted file mode 100644 index f2a98aec70f0..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import * as Sentry from '@sentry/node'; - -class MockOpenAI { - constructor(config) { - this.apiKey = config.apiKey; - - this.chat = { - completions: { - create: async params => { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - return { - id: 'chatcmpl-completions-truncation-test', - object: 'chat.completion', - created: 1677652288, - model: params.model, - system_fingerprint: 'fp_44709d6fcb', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Response to truncated messages', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 15, - total_tokens: 25, - }, - }; - }, - }, - }; - } -} - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const mockClient = new MockOpenAI({ - apiKey: 'mock-api-key', - }); - - const client = Sentry.instrumentOpenAiClient(mockClient, { enableTruncation: true, recordInputs: true }); - - // Test 1: Given an array of messages only the last message should be kept - // The last message should be truncated to fit within the 20KB limit - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated, only C's remain) - - await client.chat.completions.create({ - model: 'gpt-3.5-turbo', - messages: [ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: largeContent3 }, - ], - temperature: 0.7, - }); - - // Test 2: Given an array of messages only the last message should be kept - // The last message is small, so it should be kept intact - const smallContent = 'This is a small message that fits within the limit'; - await client.chat.completions.create({ - model: 'gpt-3.5-turbo', - messages: [ - { role: 'system', content: largeContent1 }, - { role: 'user', content: largeContent2 }, - { role: 'user', content: smallContent }, - ], - temperature: 0.7, - }); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs deleted file mode 100644 index 601f6200d13c..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs +++ /dev/null @@ -1,92 +0,0 @@ -import * as Sentry from '@sentry/node'; - -class MockOpenAI { - constructor(config) { - this.apiKey = config.apiKey; - - this.responses = { - create: async params => { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - return { - id: 'chatcmpl-responses-truncation-test', - object: 'response', - created_at: 1677652288, - status: 'completed', - error: null, - incomplete_details: null, - instructions: null, - max_output_tokens: null, - model: params.model, - output: [ - { - type: 'message', - id: 'message-123', - status: 'completed', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Response to truncated messages', - annotations: [], - }, - ], - }, - ], - parallel_tool_calls: true, - previous_response_id: null, - reasoning: { - effort: null, - summary: null, - }, - store: true, - temperature: params.temperature, - text: { - format: { - type: 'text', - }, - }, - tool_choice: 'auto', - tools: [], - top_p: 1.0, - truncation: 'disabled', - usage: { - input_tokens: 10, - input_tokens_details: { - cached_tokens: 0, - }, - output_tokens: 15, - output_tokens_details: { - reasoning_tokens: 0, - }, - total_tokens: 25, - }, - user: null, - metadata: {}, - }; - }, - }; - } -} - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const mockClient = new MockOpenAI({ - apiKey: 'mock-api-key', - }); - - const client = Sentry.instrumentOpenAiClient(mockClient, { enableTruncation: true, recordInputs: true }); - - // Create 1 large message that gets truncated to fit within the 20KB limit - const largeContent = 'A'.repeat(25000) + 'B'.repeat(25000); // ~50KB gets truncated to include only As - - await client.responses.create({ - model: 'gpt-3.5-turbo', - input: largeContent, - temperature: 0.7, - }); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs deleted file mode 100644 index 12aa5902f889..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [ - Sentry.vercelAIIntegration({ - recordInputs: true, - recordOutputs: true, - enableTruncation: false, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-truncation.mjs deleted file mode 100644 index 7ea31bb0bfdc..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-truncation.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - traceLifecycle: 'static', - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration({ enableTruncation: true })], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-message-truncation.mjs deleted file mode 100644 index 6c3f7327e64b..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-message-truncation.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const largeContent1 = 'A'.repeat(15000); // ~15KB - const largeContent2 = 'B'.repeat(15000); // ~15KB - const largeContent3 = 'C'.repeat(25000) + 'D'.repeat(25000); // ~50KB (will be truncated) - - // Test 1: Messages array with large last message that gets truncated - // Only the last message should be kept, and it should be truncated to only Cs - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 5 }, - text: 'Response to truncated messages', - }), - }), - messages: [ - { role: 'user', content: largeContent1 }, - { role: 'assistant', content: largeContent2 }, - { role: 'user', content: largeContent3 }, - ], - }); - - // Test 2: Messages array where last message is small and kept intact - const smallContent = 'This is a small message that fits within the limit'; - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 5 }, - text: 'Response to small message', - }), - }), - messages: [ - { role: 'user', content: largeContent1 }, - { role: 'assistant', content: largeContent2 }, - { role: 'user', content: smallContent }, - ], - }); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs deleted file mode 100644 index 415c13ef9acf..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // Multiple messages with long content (would normally be truncated and popped to last message only) - const longContent = 'A'.repeat(50_000); - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 5 }, - text: 'Response', - }), - }), - messages: [ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ], - }); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs deleted file mode 100644 index 3961dba5ba9f..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/instrument-with-truncation.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - dataCollection: { genAI: { inputs: true, outputs: true } }, - transport: loggingTransport, - traceLifecycle: 'stream', - integrations: [ - Sentry.vercelAIIntegration({ - enableTruncation: true, - }), - ], -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs deleted file mode 100644 index ebe0becaad35..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/scenario-truncation.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // Single long message so truncation must crop it - const longContent = 'A'.repeat(50_000); - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 5 }, - text: 'Response', - }), - }), - messages: [{ role: 'user', content: longContent }], - }); - }); - - // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits - await Sentry.flush(2000); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts index 2f61639c50b6..0211c3ff60e7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts @@ -18,7 +18,7 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; -import { getStringAttributeValue, isOrchestrionEnabled } from '../../../../utils'; +import { isOrchestrionEnabled } from '../../../../utils'; /** * Helper to match a typed attribute value in a SerializedStreamedSpan. @@ -301,48 +301,4 @@ describe('Vercel AI integration (streaming v4)', () => { await createRunner().ignore('event').expect({ span: EXPECTED_SPANS_ERROR_IN_TOOL }).start().completed(); }); }); - - const streamingLongContent = 'A'.repeat(50_000); - - createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument.mjs', (createRunner, test) => { - test('automatically disables truncation when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(streamingLongContent), - ); - expect(chatSpan).toBeDefined(); - }, - }) - .start() - .completed(); - }); - }); - - createEsmAndCjsTests(__dirname, 'scenario-truncation.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { - test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { - await createRunner() - .expect({ - span: container => { - const spans = container.items; - - // With explicit enableTruncation: true, content should be truncated despite streaming. - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.startsWith( - '[{"role":"user","content":"AAAA', - ), - ); - expect(chatSpan).toBeDefined(); - expect( - (getStringAttributeValue(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES].value) ?? '').length, - ).toBeLessThan(streamingLongContent.length); - }, - }) - .start() - .completed(); - }); - }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index d68a3eae4f5e..4238e976e858 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -463,53 +463,6 @@ describe('Vercel AI integration (v4)', () => { }, ); - createEsmAndCjsTests( - __dirname, - 'scenario-message-truncation.mjs', - 'instrument-with-truncation.mjs', - (createRunner, test) => { - test('truncates messages when they exceed byte limit', async () => { - await createRunner() - .ignore('event') - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(4); - const truncatedInvokeAgentSpan = container.items.find( - span => - span.name === 'invoke_agent' && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.match( - /^\[.*"(?:text|content)":"C+".*\]$/, - ), - ); - expect(truncatedInvokeAgentSpan).toBeDefined(); - expect(truncatedInvokeAgentSpan!.name).toBe('invoke_agent'); - expect(truncatedInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); - expect(truncatedInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toMatch( - /^\[.*"(?:text|content)":"C+".*\]$/, - ); - - const smallMessageInvokeAgentSpan = container.items.find( - span => - span.name === 'invoke_agent' && - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes( - 'This is a small message that fits within the limit', - ), - ); - expect(smallMessageInvokeAgentSpan).toBeDefined(); - expect(smallMessageInvokeAgentSpan!.name).toBe('invoke_agent'); - expect(smallMessageInvokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); - expect(smallMessageInvokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toContain( - 'This is a small message that fits within the limit', - ); - }, - }) - .start() - .completed(); - }); - }, - ); - createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates embedding related spans with genAI recording disabled', async () => { await createRunner() @@ -596,52 +549,6 @@ describe('Vercel AI integration (v4)', () => { }); }); - const longContent = 'A'.repeat(50_000); - - createEsmAndCjsTests( - __dirname, - 'scenario-no-truncation.mjs', - 'instrument-no-truncation.mjs', - (createRunner, test) => { - test('does not truncate input messages when enableTruncation is false', async () => { - await createRunner() - .expect({ transaction: { transaction: 'main' } }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const invokeAgentSpan = container.items.find( - span => - span.name === 'invoke_agent' && - span.attributes[GEN_AI_INPUT_MESSAGES]?.value === - JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]), - ); - expect(invokeAgentSpan).toBeDefined(); - expect(invokeAgentSpan!.name).toBe('invoke_agent'); - expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); - expect(invokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( - JSON.stringify([ - { role: 'user', content: longContent }, - { role: 'assistant', content: 'Some reply' }, - { role: 'user', content: 'Follow-up question' }, - ]), - ); - - const generateContentSpan = container.items.find(span => span.name === 'generate_content mock-model-id'); - expect(generateContentSpan).toBeDefined(); - expect(generateContentSpan!.name).toBe('generate_content mock-model-id'); - expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content'); - }, - }) - .start() - .completed(); - }); - }, - ); - createEsmAndCjsTests(__dirname, 'scenario-stream-text.mjs', 'instrument.mjs', (createRunner, test) => { test('creates ai spans for streamText (doStream)', async () => { await createRunner() diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index d16d02cf8d4e..dc6eaaa8e09d 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -25,12 +25,7 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; import type { InstrumentedMethodEntry } from '../core/utils'; -import { - resolveAIRecordingOptions, - setTokenUsageAttributes, - shouldEnableTruncation, - wrapPromiseWithMethods, -} from '../core/utils'; +import { resolveAIRecordingOptions, setTokenUsageAttributes, wrapPromiseWithMethods } from '../core/utils'; import { ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; @@ -88,13 +83,9 @@ export function extractRequestAttributes( * Add private request attributes to spans. * This is only recorded if recordInputs is true. */ -export function addPrivateRequestAttributes( - span: Span, - params: Record, - enableTruncation: boolean, -): void { +export function addPrivateRequestAttributes(span: Span, params: Record): void { const messages = messagesFromParams(params); - setMessagesAttribute(span, messages, enableTruncation); + setMessagesAttribute(span, messages); if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT]: JSON.stringify(params.prompt) }); @@ -225,7 +216,7 @@ function handleStreamingRequest( originalResult = target.apply(invocationThis, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); + addPrivateRequestAttributes(span, params); } return (async () => { @@ -247,7 +238,7 @@ function handleStreamingRequest( return startSpanManual(spanConfig, span => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); + addPrivateRequestAttributes(span, params); } // The helper synchronously delegates to `create`; suppress that one internal call so it // does not produce a duplicate child span (see the dedup gate in `instrumentMethod`). @@ -326,7 +317,7 @@ function instrumentMethod( originalResult = target.apply(invocationThis, args) as Promise; if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); + addPrivateRequestAttributes(span, params); } return originalResult.then( diff --git a/packages/server-utils/src/ai/anthropic-ai/types.ts b/packages/server-utils/src/ai/anthropic-ai/types.ts index dd61ff63e264..ba281ef82a0d 100644 --- a/packages/server-utils/src/ai/anthropic-ai/types.ts +++ b/packages/server-utils/src/ai/anthropic-ai/types.ts @@ -9,11 +9,6 @@ export interface AnthropicAiOptions { * Enable or disable output recording. */ recordOutputs?: boolean; - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } export type Message = { diff --git a/packages/server-utils/src/ai/anthropic-ai/utils.ts b/packages/server-utils/src/ai/anthropic-ai/utils.ts index f1c20787ee59..e7aede34822c 100644 --- a/packages/server-utils/src/ai/anthropic-ai/utils.ts +++ b/packages/server-utils/src/ai/anthropic-ai/utils.ts @@ -1,13 +1,13 @@ -import { captureException, SPAN_STATUS_ERROR, stringify } from '@sentry/core'; +import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; import type { Span, SpanStatusType } from '@sentry/core'; import { GEN_AI_INPUT_MESSAGES, GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conventions/attributes'; -import { extractSystemInstructions, getTruncatedJsonString } from '../core/utils'; +import { extractSystemInstructions, getGenAiMessagesJsonString } from '../core/utils'; import type { AnthropicAiResponse } from './types'; /** * Set the input messages attribute, extracting system instructions before truncation. */ -export function setMessagesAttribute(span: Span, messages: unknown, enableTruncation: boolean): void { +export function setMessagesAttribute(span: Span, messages: unknown): void { if (Array.isArray(messages) && messages.length === 0) { return; } @@ -21,7 +21,7 @@ export function setMessagesAttribute(span: Span, messages: unknown, enableTrunca } span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), + [GEN_AI_INPUT_MESSAGES]: getGenAiMessagesJsonString(filteredMessages), }); } diff --git a/packages/server-utils/src/ai/core/mediaStripping.ts b/packages/server-utils/src/ai/core/mediaStripping.ts index cb8e5d7b959e..7f0988bd5e94 100644 --- a/packages/server-utils/src/ai/core/mediaStripping.ts +++ b/packages/server-utils/src/ai/core/mediaStripping.ts @@ -195,3 +195,78 @@ export function stripInlineMediaFromSingleMessage(part: ContentMedia): ContentMe } return strip; } + +/** + * Message with the OpenAI/Anthropic `content: [...]` array format. + */ +type ContentArrayMessage = { + [key: string]: unknown; + content: unknown[]; +}; + +/** + * Message with the Google GenAI `parts: [...]` format. + */ +type PartsMessage = { + [key: string]: unknown; + parts: unknown[]; +}; + +/** + * Check if a message has the OpenAI/Anthropic content array format. + */ +function isContentArrayMessage(message: unknown): message is ContentArrayMessage { + return message !== null && typeof message === 'object' && 'content' in message && Array.isArray(message.content); +} + +/** + * Check if a message has the Google GenAI parts format. + */ +function isPartsMessage(message: unknown): message is PartsMessage { + return ( + message !== null && + typeof message === 'object' && + 'parts' in message && + Array.isArray((message as PartsMessage).parts) && + (message as PartsMessage).parts.length > 0 + ); +} + +/** + * Strip inline media from an array of messages, returning a new array. + * + * This does NOT mutate the input, because the actual API/client still needs the real media. + * Recurses into OpenAI/Anthropic `content: [...]` arrays and Google GenAI `parts: [...]`, replacing + * inline binary/base64 data with a placeholder while preserving all other structure. + */ +export function stripInlineMediaFromMessages(messages: unknown[]): unknown[] { + return messages.map(message => { + let newMessage: Record | undefined = undefined; + if (!!message && typeof message === 'object') { + if (isContentArrayMessage(message)) { + newMessage = { + ...message, + content: stripInlineMediaFromMessages(message.content), + }; + } else if ('content' in message && isContentMedia(message.content)) { + newMessage = { + ...message, + content: stripInlineMediaFromSingleMessage(message.content), + }; + } + if (isPartsMessage(message)) { + newMessage = { + // might have to strip content AND parts + ...(newMessage ?? message), + parts: stripInlineMediaFromMessages(message.parts), + }; + } + if (isContentMedia(newMessage)) { + newMessage = stripInlineMediaFromSingleMessage(newMessage); + } else if (isContentMedia(message)) { + newMessage = stripInlineMediaFromSingleMessage(message); + } + } + return newMessage ?? message; + }); +} diff --git a/packages/server-utils/src/ai/core/messageTruncation.ts b/packages/server-utils/src/ai/core/messageTruncation.ts deleted file mode 100644 index 779cf332855b..000000000000 --- a/packages/server-utils/src/ai/core/messageTruncation.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { isContentMedia, stripInlineMediaFromSingleMessage } from './mediaStripping'; - -/** - * Default maximum size in bytes for GenAI messages. - * Messages exceeding this limit will be truncated. - */ -export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; - -/** - * Message format used by OpenAI and Anthropic APIs. - */ -type ContentMessage = { - [key: string]: unknown; - content: string; -}; - -/** - * One block inside OpenAI / Anthropic `content: [...]` arrays (text, image_url, etc.). - */ -type ContentArrayBlock = { - [key: string]: unknown; - type: string; -}; - -/** - * Message format used by OpenAI and Anthropic APIs for media. - */ -type ContentArrayMessage = { - [key: string]: unknown; - content: ContentArrayBlock[]; -}; - -/** - * Message format used by Google GenAI API. - * Parts can be strings or objects with a text property. - */ -type PartsMessage = { - [key: string]: unknown; - parts: Array; -}; - -/** - * A part in a Google GenAI message that contains text. - */ -type TextPart = string | { text: string }; - -/** - * A part in a Google GenAI that contains media. - */ -type MediaPart = { - type: string; - content: string; -}; - -/** - * One element of an array-based message: OpenAI/Anthropic `content[]` or Google `parts`. - */ -type ArrayMessageItem = TextPart | MediaPart | ContentArrayBlock; - -/** - * Calculate the UTF-8 byte length of a string. - */ -const utf8Bytes = (text: string): number => { - return new TextEncoder().encode(text).length; -}; - -/** - * Calculate the UTF-8 byte length of a value's JSON representation. - */ -const jsonBytes = (value: unknown): number => { - return utf8Bytes(JSON.stringify(value)); -}; - -/** - * Truncate a string to fit within maxBytes (inclusive) when encoded as UTF-8. - * Uses binary search for efficiency with multi-byte characters. - * - * @param text - The string to truncate - * @param maxBytes - Maximum byte length (inclusive, UTF-8 encoded) - * @returns Truncated string whose UTF-8 byte length is at most maxBytes - */ -function truncateTextByBytes(text: string, maxBytes: number): string { - if (utf8Bytes(text) <= maxBytes) { - return text; - } - - let low = 0; - let high = text.length; - let bestFit = ''; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const candidate = text.slice(0, mid); - const byteSize = utf8Bytes(candidate); - - if (byteSize <= maxBytes) { - bestFit = candidate; - low = mid + 1; - } else { - high = mid - 1; - } - } - - return bestFit; -} - -/** - * Extract text content from a message item. - * Handles plain strings and objects with a text property. - * - * @returns The text content - */ -function getItemText(item: ArrayMessageItem): string { - if (typeof item === 'string') { - return item; - } - if ('text' in item && typeof item.text === 'string') { - return item.text; - } - return ''; -} - -/** - * Create a new item with updated text content while preserving the original structure. - * - * @param item - Original item (string or object) - * @param text - New text content - * @returns New item with updated text - */ -function withItemText(item: ArrayMessageItem, text: string): ArrayMessageItem { - if (typeof item === 'string') { - return text; - } - return { ...item, text }; -} - -/** - * Check if a message has the OpenAI/Anthropic content format. - */ -function isContentMessage(message: unknown): message is ContentMessage { - return ( - message !== null && - typeof message === 'object' && - 'content' in message && - typeof (message as ContentMessage).content === 'string' - ); -} - -/** - * Check if a message has the OpenAI/Anthropic content array format. - */ -function isContentArrayMessage(message: unknown): message is ContentArrayMessage { - return message !== null && typeof message === 'object' && 'content' in message && Array.isArray(message.content); -} - -/** - * Check if a message has the Google GenAI parts format. - */ -function isPartsMessage(message: unknown): message is PartsMessage { - return ( - message !== null && - typeof message === 'object' && - 'parts' in message && - Array.isArray((message as PartsMessage).parts) && - (message as PartsMessage).parts.length > 0 - ); -} - -/** - * Truncate a message with `content: string` format (OpenAI/Anthropic). - * - * @param message - Message with content property - * @param maxBytes - Maximum byte limit - * @returns Array with truncated message, or empty array if it doesn't fit - */ -function truncateContentMessage(message: ContentMessage, maxBytes: number): unknown[] { - // Calculate overhead (message structure without content) - const emptyMessage = { ...message, content: '' }; - const overhead = jsonBytes(emptyMessage); - const availableForContent = maxBytes - overhead; - - if (availableForContent <= 0) { - return []; - } - - const truncatedContent = truncateTextByBytes(message.content, availableForContent); - return [{ ...message, content: truncatedContent }]; -} - -/** - * Extracts the array items and their key from an array-based message. - * Returns `null` key if neither `parts` nor `content` is a valid array. - */ -function getArrayItems(message: PartsMessage | ContentArrayMessage): { - key: 'parts' | 'content' | null; - items: ArrayMessageItem[]; -} { - if ('parts' in message && Array.isArray(message.parts)) { - return { key: 'parts', items: message.parts }; - } - if ('content' in message && Array.isArray(message.content)) { - return { key: 'content', items: message.content }; - } - return { key: null, items: [] }; -} - -/** - * Truncate a message with an array-based format. - * Handles both `parts: [...]` (Google GenAI) and `content: [...]` (OpenAI/Anthropic multimodal). - * Keeps as many complete items as possible, only truncating the first item if needed. - * - * @param message - Message with parts or content array - * @param maxBytes - Maximum byte limit - * @returns Array with truncated message, or empty array if it doesn't fit - */ -function truncateArrayMessage(message: PartsMessage | ContentArrayMessage, maxBytes: number): unknown[] { - const { key, items } = getArrayItems(message); - - if (key === null || items.length === 0) { - return []; - } - - // Calculate overhead by creating empty text items - const emptyItems = items.map(item => withItemText(item, '')); - const overhead = jsonBytes({ ...message, [key]: emptyItems }); - let remainingBytes = maxBytes - overhead; - - if (remainingBytes <= 0) { - return []; - } - - // Include items until we run out of space - const includedItems: ArrayMessageItem[] = []; - - for (const item of items) { - const text = getItemText(item); - const textSize = utf8Bytes(text); - - if (textSize <= remainingBytes) { - // Item fits: include it as-is - includedItems.push(item); - remainingBytes -= textSize; - } else if (includedItems.length === 0) { - // First item doesn't fit: truncate it - const truncated = truncateTextByBytes(text, remainingBytes); - if (truncated) { - includedItems.push(withItemText(item, truncated)); - } - break; - } else { - // Subsequent item doesn't fit: stop here - break; - } - } - - /* c8 ignore start - * for type safety only, algorithm guarantees SOME text included */ - if (includedItems.length <= 0) { - return []; - } else { - /* c8 ignore stop */ - return [{ ...message, [key]: includedItems }]; - } -} - -/** - * Truncate a single message to fit within maxBytes. - * - * Supports three message formats: - * - OpenAI/Anthropic: `{ ..., content: string }` - * - Vercel AI/OpenAI multimodal: `{ ..., content: Array<{type, text?, ...}> }` - * - Google GenAI: `{ ..., parts: Array }` - * - * @param message - The message to truncate - * @param maxBytes - Maximum byte limit for the message - * @returns Array containing the truncated message, or empty array if truncation fails - */ -function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { - if (!message) return []; - - // Handle plain strings (e.g., embeddings input) - if (typeof message === 'string') { - const truncated = truncateTextByBytes(message, maxBytes); - return truncated ? [truncated] : []; - } - - if (typeof message !== 'object') { - return []; - } - - if (isContentMessage(message)) { - return truncateContentMessage(message, maxBytes); - } - - if (isContentArrayMessage(message) || isPartsMessage(message)) { - return truncateArrayMessage(message, maxBytes); - } - - // Unknown message format: cannot truncate safely - return []; -} - -/** - * Strip the inline media from message arrays. - * - * This returns a stripped message. We do NOT want to mutate the data in place, - * because of course we still want the actual API/client to handle the media. - */ -function stripInlineMediaFromMessages(messages: unknown[]): unknown[] { - const stripped = messages.map(message => { - let newMessage: Record | undefined = undefined; - if (!!message && typeof message === 'object') { - if (isContentArrayMessage(message)) { - newMessage = { - ...message, - content: stripInlineMediaFromMessages(message.content), - }; - } else if ('content' in message && isContentMedia(message.content)) { - newMessage = { - ...message, - content: stripInlineMediaFromSingleMessage(message.content), - }; - } - if (isPartsMessage(message)) { - newMessage = { - // might have to strip content AND parts - ...(newMessage ?? message), - parts: stripInlineMediaFromMessages(message.parts), - }; - } - if (isContentMedia(newMessage)) { - newMessage = stripInlineMediaFromSingleMessage(newMessage); - } else if (isContentMedia(message)) { - newMessage = stripInlineMediaFromSingleMessage(message); - } - } - return newMessage ?? message; - }); - return stripped; -} - -/** - * Truncate an array of messages to fit within a byte limit. - * - * Strategy: - * - Always keeps only the last (newest) message - * - Strips inline media from the message - * - Truncates the message content if it exceeds the byte limit - * - * @param messages - Array of messages to truncate - * @param maxBytes - Maximum total byte limit for the message - * @returns Array containing only the last message (possibly truncated) - * - * @example - * ```ts - * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 - * const truncated = truncateMessagesByBytes(messages, 10000); - * // Returns [msg4] (truncated if needed) - * ``` - */ -function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { - // Early return for empty or invalid input - if (!Array.isArray(messages) || messages.length === 0) { - return messages; - } - - // The result is always a single-element array that callers wrap with - // JSON.stringify([message]), so subtract the 2-byte array wrapper ("[" and "]") - // to ensure the final serialized value stays under the limit. - const effectiveMaxBytes = maxBytes - 2; - - // Always keep only the last message - const lastMessage = messages[messages.length - 1]; - - // Strip inline media from the single message - const stripped = stripInlineMediaFromMessages([lastMessage]); - const strippedMessage = stripped[0]; - - // Check if it fits - const messageBytes = jsonBytes(strippedMessage); - if (messageBytes <= effectiveMaxBytes) { - return stripped; - } - - // Truncate the single message if needed - return truncateSingleMessage(strippedMessage, effectiveMaxBytes); -} - -/** - * Truncate GenAI messages using the default byte limit. - * - * Convenience wrapper around `truncateMessagesByBytes` with the default limit. - * - * @param messages - Array of messages to truncate - * @returns Truncated array of messages - */ -export function truncateGenAiMessages(messages: unknown[]): unknown[] { - return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); -} - -/** - * Truncate GenAI string input using the default byte limit. - * - * @param input - The string to truncate - * @returns Truncated string - */ -export function truncateGenAiStringInput(input: string): string { - return truncateTextByBytes(input, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); -} diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts index 6ed04d1ab0f3..aab6a0214eb8 100644 --- a/packages/server-utils/src/ai/core/utils.ts +++ b/packages/server-utils/src/ai/core/utils.ts @@ -15,7 +15,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; -import { truncateGenAiMessages, truncateGenAiStringInput } from './messageTruncation'; +import { stripInlineMediaFromMessages } from './mediaStripping'; export interface AIRecordingOptions { recordInputs?: boolean; @@ -55,22 +55,6 @@ export function resolveAIRecordingOptions(options? } as T & Required; } -/** - * Resolves whether truncation should be enabled. - * If the user explicitly set `enableTruncation`, that value is used. - * Otherwise, truncation is disabled because gen_ai spans are always sent through the v2 span path - * (full span streaming via `traceLifecycle: 'stream'`, or extraction into a v2 span envelope for - * static transactions). That path is not subject to the transaction payload-size limits that - * truncation works around, so the full message data can be retained. - */ -export function shouldEnableTruncation(enableTruncation: boolean | undefined): boolean { - if (enableTruncation !== undefined) { - return enableTruncation; - } - - return !getClient(); -} - /** * Build method path from current traversal */ @@ -185,20 +169,22 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp } /** - * Get the truncated JSON string for a string, an array of messages, or an object. + * Serialize a string, an array of messages, or an object to a JSON string for span attributes, + * stripping inline media (base64 blobs, data URIs, etc.) from message arrays so large binary + * payloads never end up in span attributes. * - * @param value - The value to truncate and serialize - * @returns The truncated JSON string + * @param value - The value to serialize + * @returns The JSON string */ -export function getTruncatedJsonString(value: T | T[]): string { +export function getGenAiMessagesJsonString(value: T | T[]): string { if (typeof value === 'string') { // Some values are already JSON strings, so we don't need to duplicate the JSON parsing - return truncateGenAiStringInput(value); + return value; } - // Both truncation (media stripping recurses the value) and `JSON.stringify` can throw on - // circular refs or non-serializable values (e.g. BigInt); never let that crash instrumentation. + // Media stripping recurses the value and `JSON.stringify` can throw on circular refs or + // non-serializable values (e.g. BigInt); never let that crash instrumentation. try { - return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value); + return JSON.stringify(Array.isArray(value) ? stripInlineMediaFromMessages(value) : value); } catch { return '[unserializable]'; } diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index 307bb25b708f..02d9b41645e5 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -7,7 +7,6 @@ import { startSpan, startSpanManual, handleCallbackErrors, - stringify, } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { @@ -35,9 +34,8 @@ import type { InstrumentedMethodEntry } from '../core/utils'; import { buildMethodPath, extractSystemInstructions, - getTruncatedJsonString, + getGenAiMessagesJsonString, resolveAIRecordingOptions, - shouldEnableTruncation, } from '../core/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -143,12 +141,7 @@ export function extractRequestAttributes( * This is only recorded if recordInputs is true. * Handles different parameter formats for different Google GenAI methods. */ -export function addPrivateRequestAttributes( - span: Span, - params: Record, - operationName: string, - enableTruncation: boolean, -): void { +export function addPrivateRequestAttributes(span: Span, params: Record, operationName: string): void { if (operationName === 'embeddings') { const contents = params.contents; if (contents != null) { @@ -193,9 +186,7 @@ export function addPrivateRequestAttributes( } span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: enableTruncation - ? getTruncatedJsonString(filteredMessages) - : stringify(filteredMessages), + [GEN_AI_INPUT_MESSAGES]: getGenAiMessagesJsonString(filteredMessages), }); } } @@ -296,12 +287,7 @@ function instrumentMethod( async (span: Span) => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes( - span, - params, - operationName, - shouldEnableTruncation(options.enableTruncation), - ); + addPrivateRequestAttributes(span, params, operationName); } const stream = await target.apply(context, args); return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; @@ -329,7 +315,7 @@ function instrumentMethod( }, (span: Span) => { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); + addPrivateRequestAttributes(span, params, operationName); } return handleCallbackErrors( diff --git a/packages/server-utils/src/ai/google-genai/types.ts b/packages/server-utils/src/ai/google-genai/types.ts index 35ca728a4a60..d1729dd68f36 100644 --- a/packages/server-utils/src/ai/google-genai/types.ts +++ b/packages/server-utils/src/ai/google-genai/types.ts @@ -9,11 +9,6 @@ export interface GoogleGenAIOptions { * Enable or disable output recording. */ recordOutputs?: boolean; - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } /** diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index 37b6f98b8b0a..f7cbe6b50588 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -16,7 +16,7 @@ import { GEN_AI_TOOL_NAME, GEN_AI_TOOL_OUTPUT, } from '@sentry/conventions/attributes'; -import { resolveAIRecordingOptions, shouldEnableTruncation } from '../core/utils'; +import { resolveAIRecordingOptions } from '../core/utils'; import { LANGCHAIN_ORIGIN } from './constants'; import type { LangChainCallbackHandler, @@ -42,7 +42,6 @@ import { */ export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options); - const enableTruncation = shouldEnableTruncation(options.enableTruncation); // Internal state - single instance tracks all spans const spanMap = new Map(); @@ -98,7 +97,6 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm as LangChainSerialized, prompts, recordInputs, - enableTruncation, invocationParams, metadata, ); @@ -138,7 +136,6 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): llm as LangChainSerialized, messages as LangChainMessage[][], recordInputs, - enableTruncation, invocationParams, metadata, ); diff --git a/packages/server-utils/src/ai/langchain/types.ts b/packages/server-utils/src/ai/langchain/types.ts index 7acc6409695b..30034e3a1c4d 100644 --- a/packages/server-utils/src/ai/langchain/types.ts +++ b/packages/server-utils/src/ai/langchain/types.ts @@ -13,12 +13,6 @@ export interface LangChainOptions { * @default false (respects `dataCollection.genAI.outputs`) */ recordOutputs?: boolean; - - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } /** diff --git a/packages/server-utils/src/ai/langchain/utils.ts b/packages/server-utils/src/ai/langchain/utils.ts index e380b0316ad7..c29204dcf570 100644 --- a/packages/server-utils/src/ai/langchain/utils.ts +++ b/packages/server-utils/src/ai/langchain/utils.ts @@ -27,7 +27,7 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../core/gen-ai-attributes'; import { isContentMedia, stripInlineMediaFromSingleMessage } from '../core/mediaStripping'; -import { extractSystemInstructions, getTruncatedJsonString } from '../core/utils'; +import { extractSystemInstructions } from '../core/utils'; import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; @@ -277,7 +277,6 @@ export function extractLLMRequestAttributes( llm: LangChainSerialized, prompts: string[], recordInputs: boolean, - enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, ): Record { @@ -288,11 +287,7 @@ export function extractLLMRequestAttributes( if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { const messages = prompts.map(p => ({ role: 'user', content: p })); - setIfDefined( - attrs, - GEN_AI_INPUT_MESSAGES, - enableTruncation ? getTruncatedJsonString(messages) : stringify(messages), - ); + setIfDefined(attrs, GEN_AI_INPUT_MESSAGES, stringify(messages)); } return attrs; @@ -311,7 +306,6 @@ export function extractChatModelRequestAttributes( llm: LangChainSerialized, langChainMessages: LangChainMessage[][], recordInputs: boolean, - enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, ): Record { @@ -329,11 +323,7 @@ export function extractChatModelRequestAttributes( setIfDefined(attrs, GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - setIfDefined( - attrs, - GEN_AI_INPUT_MESSAGES, - enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), - ); + setIfDefined(attrs, GEN_AI_INPUT_MESSAGES, stringify(filteredMessages)); } return attrs; diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index 3f4feeb5f2ca..831716362c49 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -18,12 +18,7 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS, } from '@sentry/conventions/attributes'; import { GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { - extractSystemInstructions, - getTruncatedJsonString, - resolveAIRecordingOptions, - shouldEnableTruncation, -} from '../core/utils'; +import { extractSystemInstructions, resolveAIRecordingOptions } from '../core/utils'; import { createLangChainCallbackHandler } from '../langchain'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -174,11 +169,8 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - const enableTruncation = shouldEnableTruncation(options.enableTruncation); span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: enableTruncation - ? getTruncatedJsonString(filteredMessages) - : stringify(filteredMessages), + [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), }); } diff --git a/packages/server-utils/src/ai/langgraph/types.ts b/packages/server-utils/src/ai/langgraph/types.ts index 021099f369b1..b16f9718c69e 100644 --- a/packages/server-utils/src/ai/langgraph/types.ts +++ b/packages/server-utils/src/ai/langgraph/types.ts @@ -7,11 +7,6 @@ export interface LangGraphOptions { * Enable or disable output recording. */ recordOutputs?: boolean; - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } /** diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index d61e9c580861..4acfbf01c7b7 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -7,7 +7,6 @@ import { startSpan, startSpanManual, debug, - stringify, } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { @@ -23,9 +22,8 @@ import type { InstrumentedMethodEntry } from '../core/utils'; import { buildMethodPath, extractSystemInstructions, - getTruncatedJsonString, + getGenAiMessagesJsonString, resolveAIRecordingOptions, - shouldEnableTruncation, wrapPromiseWithMethods, } from '../core/utils'; import { OPENAI_METHOD_REGISTRY } from './constants'; @@ -83,12 +81,7 @@ export function extractRequestAttributes(args: unknown[], operationName: string) } // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. -export function addRequestAttributes( - span: Span, - params: Record, - operationName: string, - enableTruncation: boolean, -): void { +export function addRequestAttributes(span: Span, params: Record, operationName: string): void { // Store embeddings input on a separate attribute and do not truncate it if (operationName === 'embeddings' && 'input' in params) { const input = params.input; @@ -129,10 +122,7 @@ export function addRequestAttributes( span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - span.setAttribute( - GEN_AI_INPUT_MESSAGES, - enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), - ); + span.setAttribute(GEN_AI_INPUT_MESSAGES, getGenAiMessagesJsonString(filteredMessages)); } /** @@ -168,7 +158,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); + addRequestAttributes(span, params, operationName); } // Return async processing @@ -206,7 +196,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); + addRequestAttributes(span, params, operationName); } return originalResult.then( diff --git a/packages/server-utils/src/ai/openai/types.ts b/packages/server-utils/src/ai/openai/types.ts index 794c7ca49f8a..dd6872bb691b 100644 --- a/packages/server-utils/src/ai/openai/types.ts +++ b/packages/server-utils/src/ai/openai/types.ts @@ -22,11 +22,6 @@ export interface OpenAiOptions { * Enable or disable output recording. */ recordOutputs?: boolean; - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } export interface OpenAiClient { diff --git a/packages/server-utils/src/ai/vercel-ai/index.ts b/packages/server-utils/src/ai/vercel-ai/index.ts index d45104eac744..ee08ca159f7a 100644 --- a/packages/server-utils/src/ai/vercel-ai/index.ts +++ b/packages/server-utils/src/ai/vercel-ai/index.ts @@ -9,7 +9,6 @@ import { spanToJSON, } from '@sentry/core'; import type { Client, Event, Span, SpanAttributes, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '@sentry/core'; -import { shouldEnableTruncation } from '../core/utils'; import { WORKERS_AI_INTEGRATION_NAME } from '../workers-ai/constants'; import { GEN_AI_CONVERSATION_ID, @@ -96,13 +95,7 @@ function onVercelAiSpanStart(span: Span): void { _INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]); } - const client = getClient(); - const integration = client?.getIntegrationByName('VercelAI') as - | { options?: { enableTruncation?: boolean } } - | undefined; - const enableTruncation = shouldEnableTruncation(integration?.options?.enableTruncation); - - processGenerateSpan(span, name, attributes, enableTruncation); + processGenerateSpan(span, name, attributes); } function vercelAiEventProcessor(event: Event): Event { @@ -434,7 +427,7 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void { } } -function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes, enableTruncation: boolean): void { +function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes): void { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.vercelai.otel'); const nameWthoutAi = name.replace('ai.', ''); @@ -446,7 +439,7 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute span.setAttribute('gen_ai.function_id', functionId); } - requestMessagesFromPrompt(span, attributes, enableTruncation); + requestMessagesFromPrompt(span, attributes); if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL]) { span.setAttribute(GEN_AI_RESPONSE_MODEL, attributes[AI_MODEL_ID_ATTRIBUTE]); diff --git a/packages/server-utils/src/ai/vercel-ai/utils.ts b/packages/server-utils/src/ai/vercel-ai/utils.ts index 89672aa1d4a9..741d385df632 100644 --- a/packages/server-utils/src/ai/vercel-ai/utils.ts +++ b/packages/server-utils/src/ai/vercel-ai/utils.ts @@ -1,5 +1,4 @@ /* eslint-disable typescript-eslint/no-deprecated */ -import { stringify } from '@sentry/core'; import type { Span, SpanAttributes, SpanJSON, TraceContext } from '@sentry/core'; import { GEN_AI_INPUT_MESSAGES, @@ -10,7 +9,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, } from '@sentry/conventions/attributes'; -import { extractSystemInstructions, getTruncatedJsonString } from '../core/utils'; +import { extractSystemInstructions, getGenAiMessagesJsonString } from '../core/utils'; import { toolCallSpanContextMap } from './constants'; import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; @@ -221,7 +220,7 @@ export function convertUserInputToMessagesFormat(userInput: string): { role: str * Generate a request.messages JSON array from the prompt field in the * invoke_agent op */ -export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes, enableTruncation: boolean): void { +export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes): void { if ( typeof attributes[AI_PROMPT_ATTRIBUTE] === 'string' && !attributes[GEN_AI_INPUT_MESSAGES] && @@ -240,7 +239,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - const messagesJson = enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages); + const messagesJson = getGenAiMessagesJsonString(filteredMessages); span.setAttributes({ [AI_PROMPT_ATTRIBUTE]: messagesJson, @@ -260,16 +259,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - // `extractSystemInstructions` returns the original array reference unchanged when no - // system message is extracted. When truncation is also disabled, re-serializing would - // reproduce the SDK's own input string, so we reuse it instead of allocating a second - // full-size copy of the payload (matters for large prompts in memory-constrained runtimes). - const messagesJson = - !enableTruncation && filteredMessages === messages - ? originalMessagesJson - : enableTruncation - ? getTruncatedJsonString(filteredMessages) - : stringify(filteredMessages); + const messagesJson = getGenAiMessagesJsonString(filteredMessages); span.setAttributes({ [AI_PROMPT_MESSAGES_ATTRIBUTE]: messagesJson, diff --git a/packages/server-utils/src/ai/workers-ai/index.ts b/packages/server-utils/src/ai/workers-ai/index.ts index 8e003a522f27..cf5b0a467484 100644 --- a/packages/server-utils/src/ai/workers-ai/index.ts +++ b/packages/server-utils/src/ai/workers-ai/index.ts @@ -6,7 +6,7 @@ import { startSpanManual, } from '@sentry/core'; import type { Span } from '@sentry/core'; -import { resolveAIRecordingOptions, shouldEnableTruncation } from '../core/utils'; +import { resolveAIRecordingOptions } from '../core/utils'; import { WORKERS_AI_INTEGRATION_NAME } from './constants'; import { instrumentWorkersAiStream } from './streaming'; import type { WorkersAiOptions } from './types'; @@ -76,7 +76,7 @@ function instrumentRun( } if (options.recordInputs) { - addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + addRequestAttributes(span, inputs, operationName); } return originalResult.then(result => { @@ -96,7 +96,7 @@ function instrumentRun( const originalResult = originalRun.apply(context, args) as Promise; if (options.recordInputs) { - addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + addRequestAttributes(span, inputs, operationName); } return originalResult.then(result => { diff --git a/packages/server-utils/src/ai/workers-ai/types.ts b/packages/server-utils/src/ai/workers-ai/types.ts index 31a931604871..7d49d491d9db 100644 --- a/packages/server-utils/src/ai/workers-ai/types.ts +++ b/packages/server-utils/src/ai/workers-ai/types.ts @@ -1,12 +1,6 @@ import type { AIRecordingOptions } from '../core/utils'; -export interface WorkersAiOptions extends AIRecordingOptions { - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; -} +export interface WorkersAiOptions extends AIRecordingOptions {} /** * Minimal shape of the Cloudflare Workers AI binding (`env.AI`). diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 6c1407389fd8..19a8ae904a95 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -19,8 +19,7 @@ import { import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { extractSystemInstructions, getTruncatedJsonString, setTokenUsageAttributes } from '../core/utils'; -import { stringify } from '@sentry/core'; +import { extractSystemInstructions, getGenAiMessagesJsonString, setTokenUsageAttributes } from '../core/utils'; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; import type { WorkersAiInput, WorkersAiOutput } from './types'; @@ -88,12 +87,7 @@ export function extractRequestAttributes( * Record the request inputs (messages/prompt/embeddings input) on the span. * Only called when `recordInputs` is enabled. */ -export function addRequestAttributes( - span: Span, - inputs: unknown, - operationName: string, - enableTruncation: boolean, -): void { +export function addRequestAttributes(span: Span, inputs: unknown, operationName: string): void { if (!inputs || typeof inputs !== 'object') { return; } @@ -123,10 +117,7 @@ export function addRequestAttributes( span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - span.setAttribute( - GEN_AI_INPUT_MESSAGES, - enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), - ); + span.setAttribute(GEN_AI_INPUT_MESSAGES, getGenAiMessagesJsonString(filteredMessages)); } /** diff --git a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts index 04a62ecd2286..02fe7f21db78 100644 --- a/packages/server-utils/src/integrations/tracing-channel/anthropic.ts +++ b/packages/server-utils/src/integrations/tracing-channel/anthropic.ts @@ -7,7 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { resolveAIRecordingOptions, shouldEnableTruncation } from '../../ai/core/utils'; +import { resolveAIRecordingOptions } from '../../ai/core/utils'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../../ai/anthropic-ai'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../../ai/anthropic-ai/streaming'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../../ai/anthropic-ai/types'; @@ -98,7 +98,6 @@ function createGenAiSpan( const params = typeof args[0] === 'object' && args[0] !== null ? (args[0] as Record) : undefined; const { recordInputs } = resolveAIRecordingOptions(options); - const enableTruncation = shouldEnableTruncation(options.enableTruncation); const attributes = extractRequestAttributes(args, methodPath, operation); const model = (attributes[GEN_AI_REQUEST_MODEL] as string) || 'unknown'; @@ -111,7 +110,7 @@ function createGenAiSpan( }); if (recordInputs && params) { - addPrivateRequestAttributes(span, params, enableTruncation); + addPrivateRequestAttributes(span, params); } return span; diff --git a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts index 74ffcfea0ba5..5e827095030b 100644 --- a/packages/server-utils/src/integrations/tracing-channel/google-genai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/google-genai.ts @@ -9,7 +9,7 @@ import { spanToJSON, startInactiveSpan, } from '@sentry/core'; -import { resolveAIRecordingOptions, shouldEnableTruncation } from '../../ai/core/utils'; +import { resolveAIRecordingOptions } from '../../ai/core/utils'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../../ai/google-genai'; import { instrumentStream } from '../../ai/google-genai/streaming'; import type { GoogleGenAIOptions, GoogleGenAIResponse } from '../../ai/google-genai/types'; @@ -102,7 +102,6 @@ function createGenAiSpan( const params = args[0] as Record | undefined; const { recordInputs } = resolveAIRecordingOptions(options); - const enableTruncation = shouldEnableTruncation(options.enableTruncation); const attributes = extractRequestAttributes(operation, params, data.self); const model = (attributes[GEN_AI_REQUEST_MODEL] as string) || 'unknown'; @@ -115,7 +114,7 @@ function createGenAiSpan( }); if (recordInputs && params) { - addPrivateRequestAttributes(span, params, operation, enableTruncation); + addPrivateRequestAttributes(span, params, operation); } return span; diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index 851bcea05e24..0eda1f62c57c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/openai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/openai.ts @@ -6,7 +6,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { resolveAIRecordingOptions, shouldEnableTruncation } from '../../ai/core/utils'; +import { resolveAIRecordingOptions } from '../../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../../ai/openai'; import { instrumentStream } from '../../ai/openai/streaming'; import type { OpenAiOptions } from '../../ai/openai/types'; @@ -78,7 +78,6 @@ function createGenAiSpan(data: OpenAiChatChannelContext, operation: string, opti const params = args[0] as Record | undefined; const { recordInputs } = resolveAIRecordingOptions(options); - const enableTruncation = shouldEnableTruncation(options.enableTruncation); const attributes = extractRequestAttributes(args, operation); attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; @@ -91,7 +90,7 @@ function createGenAiSpan(data: OpenAiChatChannelContext, operation: string, opti }); if (recordInputs && params) { - addRequestAttributes(span, params, operation, enableTruncation); + addRequestAttributes(span, params, operation); } return span; diff --git a/packages/server-utils/src/vercel-ai/index.ts b/packages/server-utils/src/vercel-ai/index.ts index 8fb746149da0..416ed37e92ed 100644 --- a/packages/server-utils/src/vercel-ai/index.ts +++ b/packages/server-utils/src/vercel-ai/index.ts @@ -16,12 +16,6 @@ export interface VercelAiOptions { * Integration-level options take precedence over global `dataCollection` config. */ recordOutputs?: boolean; - - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; } const _vercelAiIntegration = ((options: VercelAiOptions = {}) => { diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts index 37ceb81059ee..04c5a2926f89 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts @@ -44,7 +44,7 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { getProviderMetadataAttributes } from '../ai/vercel-ai'; -import { getTruncatedJsonString, shouldEnableTruncation } from '../ai/core/utils'; +import { getGenAiMessagesJsonString } from '../ai/core/utils'; import { WORKERS_AI_INTEGRATION_NAME } from '../ai/workers-ai/constants'; import { bindTracingChannelToSpan } from '../tracing-channel'; import { asNumber, asString, isReadableStream, type StreamedModelCallResult, sum, tapModelCallStream } from './util'; @@ -207,7 +207,6 @@ export type VercelAiTracingChannelFactory = (name: string) => export interface VercelAiChannelOptions { recordInputs?: boolean; recordOutputs?: boolean; - enableTruncation?: boolean; } /** @@ -367,7 +366,7 @@ export function createSpanFromMessage( return undefined; } - const { recordInputs, enableTruncation } = getRecordingOptions(event, channelOptions); + const { recordInputs } = getRecordingOptions(event, channelOptions); const provider = asString(event.provider); const modelId = asString(event.modelId); const callId = asString(event.callId); @@ -393,11 +392,11 @@ export function createSpanFromMessage( // `generateObject` builds the same `invoke_agent` span as `generateText` (non-streaming); its // distinct `ai.generateObject` operationId rides on `event.operationId`. The JSON-schema attribute // the OTel path derives from the SDK's Zod schema is not reconstructed on the channel path. - return buildInvokeAgentSpan(event, baseAttributes, recordInputs, enableTruncation, callId, type === 'streamText'); + return buildInvokeAgentSpan(event, baseAttributes, recordInputs, callId, type === 'streamText'); case 'languageModelCall': _INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]); - return buildModelCallSpan(event, baseAttributes, recordInputs, enableTruncation, callId, modelId); + return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId); case 'executeTool': return buildToolSpan(event, recordInputs); case 'embed': @@ -430,7 +429,6 @@ function buildInvokeAgentSpan( event: Record, baseAttributes: SpanAttributes, recordInputs: boolean, - enableTruncation: boolean, callId: string | undefined, isStream: boolean, ): Span { @@ -444,7 +442,7 @@ function buildInvokeAgentSpan( [VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId, [GEN_AI_RESPONSE_STREAMING]: isStream, ...(functionId ? { [GEN_AI_FUNCTION_ID]: functionId } : {}), - ...(recordInputs ? buildInputMessageAttributes(event, enableTruncation) : {}), + ...(recordInputs ? buildInputMessageAttributes(event) : {}), }); if (isStream && callId) { invokeAgentSpanByCallId.set(callId, span); @@ -457,7 +455,6 @@ function buildModelCallSpan( event: Record, baseAttributes: SpanAttributes, recordInputs: boolean, - enableTruncation: boolean, callId: string | undefined, modelId: string | undefined, ): Span { @@ -468,7 +465,7 @@ function buildModelCallSpan( return startGenAiSpan(GEN_AI_GENERATE_CONTENT_OPERATION, modelId, { ...baseAttributes, [VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId, - ...(recordInputs ? buildInputMessageAttributes(event, enableTruncation) : {}), + ...(recordInputs ? buildInputMessageAttributes(event) : {}), ...(recordInputs && Array.isArray(event.tools) ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: stringify(event.tools) } : {}), }); } @@ -692,14 +689,12 @@ function getRecordingOptions( ): { recordInputs: boolean; recordOutputs: boolean; - enableTruncation: boolean; } { const genAI = getClient()?.getDataCollectionOptions().genAI; return { recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs), recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs), - enableTruncation: shouldEnableTruncation(channelOptions.enableTruncation), }; } @@ -723,10 +718,7 @@ function resolveRecording(integrationOption: unknown, perCallOption: unknown, gl return globalDefault === true; } -function buildInputMessageAttributes( - event: Record, - enableTruncation: boolean, -): Record { +function buildInputMessageAttributes(event: Record): Record { const attributes: Record = {}; // `ai` >= 7 forbids system messages in `messages`/`prompt` and exposes the system prompt as a @@ -741,7 +733,7 @@ function buildInputMessageAttributes( // simpler `prompt` field is used. const messages = event.messages ?? event.prompt; if (messages !== undefined) { - attributes[GEN_AI_INPUT_MESSAGES] = enableTruncation ? getTruncatedJsonString(messages) : stringify(messages); + attributes[GEN_AI_INPUT_MESSAGES] = getGenAiMessagesJsonString(messages); } return attributes; diff --git a/packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts b/packages/server-utils/test/ai/lib/tracing/ai-media-stripping.test.ts similarity index 56% rename from packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts rename to packages/server-utils/test/ai/lib/tracing/ai-media-stripping.test.ts index 86c1933ec10e..e80f692573bf 100644 --- a/packages/server-utils/test/ai/lib/tracing/ai-message-truncation.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/ai-media-stripping.test.ts @@ -1,18 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { truncateGenAiMessages, truncateGenAiStringInput } from '../../../../src/ai/core/messageTruncation'; -import { getTruncatedJsonString } from '../../../../src/ai/core/utils'; +import { stripInlineMediaFromMessages } from '../../../../src/ai/core/mediaStripping'; +import { getGenAiMessagesJsonString } from '../../../../src/ai/core/utils'; -describe('message truncation utilities', () => { - describe('truncateGenAiMessages', () => { +describe('media stripping utilities', () => { + describe('stripInlineMediaFromMessages', () => { it('leaves empty/non-array/small messages alone', () => { - // @ts-expect-error - exercising invalid type code path - expect(truncateGenAiMessages(null)).toBe(null); - expect(truncateGenAiMessages([])).toStrictEqual([]); - expect(truncateGenAiMessages([{ text: 'hello' }])).toStrictEqual([{ text: 'hello' }]); - expect(truncateGenAiStringInput('hello')).toBe('hello'); + expect(stripInlineMediaFromMessages([])).toStrictEqual([]); + expect(stripInlineMediaFromMessages([{ text: 'hello' }])).toStrictEqual([{ text: 'hello' }]); }); - it('strips inline media from messages', () => { + it('strips inline media from messages, keeping all messages', () => { const b64 = Buffer.from('lots of data\n').toString('base64'); const removed = '[Blob substitute]'; const messages = [ @@ -93,12 +90,38 @@ describe('message truncation utilities', () => { // indented json makes for better diffs in test output const messagesJson = JSON.stringify(messages, null, 2); - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); // original messages objects must not be mutated expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - // only the last message should be kept (with media stripped) + // all messages are kept, with inline media stripped expect(result).toStrictEqual([ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: removed, + }, + }, + ], + }, + { + role: 'user', + content: { + image_url: removed, + }, + }, + { + role: 'agent', + type: 'image', + content: { + b64_json: removed, + }, + }, { role: 'system', inlineData: { @@ -170,7 +193,7 @@ describe('message truncation utilities', () => { ]; const messagesJson = JSON.stringify(messages, null, 2); - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); // original messages must not be mutated expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); @@ -212,7 +235,7 @@ describe('message truncation utilities', () => { }, ]; - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); expect(result).toStrictEqual([ { @@ -256,7 +279,7 @@ describe('message truncation utilities', () => { }, ]; - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); expect(result).toStrictEqual([ { @@ -301,7 +324,7 @@ describe('message truncation utilities', () => { ]; const messagesJson = JSON.stringify(messages, null, 2); - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); @@ -347,7 +370,7 @@ describe('message truncation utilities', () => { ]; const messagesJson = JSON.stringify(messages, null, 2); - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); @@ -389,7 +412,7 @@ describe('message truncation utilities', () => { }, ]; - const result = truncateGenAiMessages(messages); + const result = stripInlineMediaFromMessages(messages); expect(result).toStrictEqual([ { @@ -407,222 +430,41 @@ describe('message truncation utilities', () => { }, ]); }); + }); - const humongous = 'this is a long string '.repeat(10_000); - const giant = 'this is a long string '.repeat(1_000); - const big = 'this is a long string '.repeat(100); - - it('keeps only the last message without truncation when it fits the limit', () => { - // Multiple messages that together exceed 20KB, but last message is small - const messages = [ - { content: `1 ${humongous}` }, - { content: `2 ${humongous}` }, - { content: `3 ${big}` }, // last message - small enough to fit - ]; - - const result = truncateGenAiMessages(messages); - - // Should only keep the last message, unchanged - expect(result).toStrictEqual([{ content: `3 ${big}` }]); - }); - - it('keeps only the last message with truncation when it does not fit the limit', () => { - const messages = [{ content: `1 ${humongous}` }, { content: `2 ${humongous}` }, { content: `3 ${humongous}` }]; - const result = truncateGenAiMessages(messages); - const truncLen = 20_000 - 2 - JSON.stringify({ content: '' }).length; - expect(result).toStrictEqual([{ content: `3 ${humongous}`.substring(0, truncLen) }]); - }); - - it('drops if last message cannot be safely truncated', () => { - const messages = [ - { content: `1 ${humongous}` }, - { content: `2 ${humongous}` }, - { what_even_is_this: `? ${humongous}` }, - ]; - const result = truncateGenAiMessages(messages); - expect(result).toStrictEqual([]); - }); - - it('fully drops message if content cannot be made to fit', () => { - const messages = [{ some_other_field: humongous, content: 'hello' }]; - expect(truncateGenAiMessages(messages)).toStrictEqual([]); - }); - - it('truncates if the message content string will not fit', () => { - const messages = [{ content: `2 ${humongous}` }]; - const result = truncateGenAiMessages(messages); - const truncLen = 20_000 - 2 - JSON.stringify({ content: '' }).length; - expect(result).toStrictEqual([{ content: `2 ${humongous}`.substring(0, truncLen) }]); - }); - - it('fully drops message if first part overhead does not fit', () => { - const messages = [ - { - parts: [{ some_other_field: humongous }], - }, - ]; - expect(truncateGenAiMessages(messages)).toStrictEqual([]); - }); - - it('fully drops message if overhead too large', () => { - const messages = [ - { - some_other_field: humongous, - parts: [], - }, - ]; - expect(truncateGenAiMessages(messages)).toStrictEqual([]); - }); - - it('truncates if the first message part will not fit', () => { - const messages = [ - { - parts: [`2 ${humongous}`, { some_other_field: 'no text here' }], - }, - ]; - - const result = truncateGenAiMessages(messages); - - // interesting (unexpected?) edge case effect of this truncation. - // subsequent messages count towards truncation overhead limit, - // but are not included, even without their text. This is an edge - // case that seems unlikely in normal usage. - const truncLen = - 20_000 - - 2 - - JSON.stringify({ - parts: ['', { some_other_field: 'no text here', text: '' }], - }).length; - - expect(result).toStrictEqual([ - { - parts: [`2 ${humongous}`.substring(0, truncLen)], - }, - ]); - }); + describe('getGenAiMessagesJsonString', () => { + it('returns a fallback instead of throwing on circular references', () => { + const circular: Record = { role: 'user', content: 'hi' }; + circular.self = circular; - it('truncates if the first message part will not fit, text object', () => { - const messages = [ - { - parts: [{ text: `2 ${humongous}` }], - }, - ]; - const result = truncateGenAiMessages(messages); - const truncLen = - 20_000 - - 2 - - JSON.stringify({ - parts: [{ text: '' }], - }).length; - expect(result).toStrictEqual([ - { - parts: [ - { - text: `2 ${humongous}`.substring(0, truncLen), - }, - ], - }, - ]); + expect(getGenAiMessagesJsonString(circular)).toBe('[unserializable]'); + expect(getGenAiMessagesJsonString([circular])).toBe('[unserializable]'); }); - it('drops if subsequent message part will not fit, text object', () => { - const messages = [ - { - parts: [ - { text: `1 ${big}` }, - { some_other_field: 'ok' }, - { text: `2 ${big}` }, - { text: `3 ${big}` }, - { text: `4 ${giant}` }, - { text: `5 ${giant}` }, - { text: `6 ${big}` }, - { text: `7 ${big}` }, - { text: `8 ${big}` }, - ], - }, - ]; - const result = truncateGenAiMessages(messages); - expect(result).toStrictEqual([ - { - parts: [{ text: `1 ${big}` }, { some_other_field: 'ok' }, { text: `2 ${big}` }, { text: `3 ${big}` }], - }, - ]); + it('returns strings as-is and serializes objects', () => { + expect(getGenAiMessagesJsonString('hello')).toBe('hello'); + expect(getGenAiMessagesJsonString({ a: 1 })).toBe('{"a":1}'); }); - it('truncates content array message when first text item does not fit', () => { + it('strips inline media from message arrays while keeping all messages', () => { + const b64 = Buffer.from('lots of data\n').toString('base64'); const messages = [ + { role: 'user', content: 'first message' }, { role: 'user', - content: [{ type: 'text', text: `2 ${humongous}` }], + content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }], }, ]; - const result = truncateGenAiMessages(messages); - const truncLen = - 20_000 - - 2 - - JSON.stringify({ - role: 'user', - content: [{ type: 'text', text: '' }], - }).length; - expect(result).toStrictEqual([ - { - role: 'user', - content: [{ type: 'text', text: `2 ${humongous}`.substring(0, truncLen) }], - }, - ]); - }); - it('drops subsequent content array items that do not fit', () => { - const messages = [ - { - role: 'assistant', - content: [ - { type: 'text', text: `1 ${big}` }, - { type: 'image_url', url: 'https://example.com/img.png' }, - { type: 'text', text: `2 ${big}` }, - { type: 'text', text: `3 ${big}` }, - { type: 'text', text: `4 ${giant}` }, - { type: 'text', text: `5 ${giant}` }, - ], - }, - ]; - const result = truncateGenAiMessages(messages); - expect(result).toStrictEqual([ - { - role: 'assistant', - content: [ - { type: 'text', text: `1 ${big}` }, - { type: 'image_url', url: 'https://example.com/img.png' }, - { type: 'text', text: `2 ${big}` }, - { type: 'text', text: `3 ${big}` }, - ], - }, - ]); - }); + const result = getGenAiMessagesJsonString(messages); - it('drops content array message if overhead is too large', () => { - const messages = [ - { - some_other_field: humongous, - content: [{ type: 'text', text: 'hello' }], - }, - ]; - expect(truncateGenAiMessages(messages)).toStrictEqual([]); + expect(result).toBe( + JSON.stringify([ + { role: 'user', content: 'first message' }, + { role: 'user', content: [{ type: 'image_url', image_url: { url: '[Blob substitute]' } }] }, + ]), + ); + expect(result).not.toContain(b64); }); }); }); - -describe('getTruncatedJsonString', () => { - it('returns a fallback instead of throwing on circular references', () => { - const circular: Record = { role: 'user', content: 'hi' }; - circular.self = circular; - - expect(getTruncatedJsonString(circular)).toBe('[unserializable]'); - expect(getTruncatedJsonString([circular])).toBe('[unserializable]'); - }); - - it('serializes normal values as before', () => { - expect(getTruncatedJsonString('hello')).toBe('hello'); - expect(getTruncatedJsonString({ a: 1 })).toBe('{"a":1}'); - }); -}); diff --git a/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts index d9a4fc442901..e8a4640797e9 100644 --- a/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/ai/utils.test.ts @@ -1,10 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core'; -import { - resolveAIRecordingOptions, - shouldEnableTruncation, - wrapPromiseWithMethods, -} from '../../../../../src/ai/core/utils'; +import { resolveAIRecordingOptions, wrapPromiseWithMethods } from '../../../../../src/ai/core/utils'; import { getDefaultTestClientOptions, TestClient } from '../../../../mocks/client'; describe('resolveAIRecordingOptions', () => { @@ -65,51 +61,6 @@ describe('resolveAIRecordingOptions', () => { }); }); -describe('shouldEnableTruncation', () => { - beforeEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - getGlobalScope().clear(); - getCurrentScope().setClient(undefined); - }); - - afterEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - getGlobalScope().clear(); - }); - - function setupClient(options: Parameters[0] = {}): void { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ...options })); - setCurrentClient(client); - client.init(); - } - - it('defaults to true when no client is set', () => { - expect(shouldEnableTruncation(undefined)).toBe(true); - }); - - it('defaults to false with a default client', () => { - setupClient(); - expect(shouldEnableTruncation(undefined)).toBe(false); - }); - - it('defaults to false when span streaming is enabled (traceLifecycle: stream)', () => { - setupClient({ traceLifecycle: 'stream' }); - expect(shouldEnableTruncation(undefined)).toBe(false); - }); - - it('explicit enableTruncation: true overrides the default', () => { - setupClient(); - expect(shouldEnableTruncation(true)).toBe(true); - }); - - it('explicit enableTruncation: false overrides the default', () => { - setupClient(); - expect(shouldEnableTruncation(false)).toBe(false); - }); -}); - describe('wrapPromiseWithMethods', () => { /** * Creates a mock APIPromise that mimics the behavior of OpenAI/Anthropic SDK APIPromise. diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts index 29eb1794e93a..0bccc3302019 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts @@ -241,7 +241,7 @@ describe('extractChatModelRequestAttributes with multimodal content', () => { ], ]; - const attrs = extractChatModelRequestAttributes(serialized, messages, true, true); + const attrs = extractChatModelRequestAttributes(serialized, messages, true); const inputMessages = attrs[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); diff --git a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts index 3d58a88f58b4..82c41bb978ce 100644 --- a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getTruncatedJsonString } from '../../../../src/ai/core/utils'; -import { stringify } from '@sentry/core'; +import { getGenAiMessagesJsonString } from '../../../../src/ai/core/utils'; import { GEN_AI_INPUT_MESSAGES, GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conventions/attributes'; import { requestMessagesFromPrompt } from '../../../../src/ai/vercel-ai/utils'; import { AI_PROMPT_MESSAGES_ATTRIBUTE } from '../../../../src/ai/vercel-ai/vercel-ai-attributes'; @@ -24,22 +23,20 @@ function createRecordingSpan(): { span: Span; recorded: Record } describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { - it('reuses the original string verbatim when no system message and truncation is off', () => { + it('serializes all messages when there is no system message', () => { const { span, recorded } = createRecordingSpan(); - // Deliberately non-canonical whitespace. Re-serializing (JSON.stringify(JSON.parse(x))) - // would strip it, so a byte-identical result proves the original string was reused. - const original = '[ { "role": "user", "content": "hello world" } ]'; - const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: original } as unknown as SpanAttributes; + const messages = [{ role: 'user', content: 'hello world' }]; + const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: JSON.stringify(messages) } as unknown as SpanAttributes; - requestMessagesFromPrompt(span, attributes, /* enableTruncation */ false); + requestMessagesFromPrompt(span, attributes); - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(original); - expect(recorded[GEN_AI_INPUT_MESSAGES]).toBe(original); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getGenAiMessagesJsonString(messages)); + expect(recorded[GEN_AI_INPUT_MESSAGES]).toBe(getGenAiMessagesJsonString(messages)); expect(recorded[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined(); }); - it('extracts the system message and re-serializes the remainder when truncation is off', () => { + it('extracts the system message and serializes the remainder', () => { const { span, recorded } = createRecordingSpan(); const original = JSON.stringify([ @@ -48,30 +45,32 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { ]); const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: original } as unknown as SpanAttributes; - requestMessagesFromPrompt(span, attributes, false); + requestMessagesFromPrompt(span, attributes); expect(recorded[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe(JSON.stringify([{ type: 'text', content: 'be nice' }])); - // System message removed; output is the SDK's own serialization of just the remainder. - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify([{ role: 'user', content: 'hello' }])); + // System message removed; output is just the remainder. + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe( + getGenAiMessagesJsonString([{ role: 'user', content: 'hello' }]), + ); expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).not.toBe(original); }); - it('keeps the truncation path untouched when truncation is on', () => { + it('keeps all messages and strips inline media', () => { const { span, recorded } = createRecordingSpan(); + const b64 = Buffer.from('lots of data\n').toString('base64'); const messages = [ { role: 'user', content: 'first' }, - { role: 'user', content: 'second' }, + { role: 'user', content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }] }, ]; - const original = JSON.stringify(messages); - const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: original } as unknown as SpanAttributes; + const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: JSON.stringify(messages) } as unknown as SpanAttributes; - requestMessagesFromPrompt(span, attributes, /* enableTruncation */ true); + requestMessagesFromPrompt(span, attributes); - // Output must equal the SDK's own truncated serialization (and therefore differ from the - // input), proving the fast-path reuse did NOT short-circuit the truncation branch. - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getTruncatedJsonString(messages)); - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).not.toBe(original); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getGenAiMessagesJsonString(messages)); + expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain('first'); + expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain('[Blob substitute]'); + expect(recorded[GEN_AI_INPUT_MESSAGES]).not.toContain(b64); }); it('does not throw and sets no attributes for malformed JSON', () => { @@ -79,7 +78,7 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: '{ not json' } as unknown as SpanAttributes; - expect(() => requestMessagesFromPrompt(span, attributes, false)).not.toThrow(); + expect(() => requestMessagesFromPrompt(span, attributes)).not.toThrow(); expect(Object.keys(recorded)).toHaveLength(0); }); }); 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 0d674fc240a7..6b427ecadc58 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 @@ -96,24 +96,23 @@ describe('anthropic-ai-utils', () => { }; const span = mock as unknown as Span; - it('sets length along with truncated value', () => { + it('sets the full message value without truncation', () => { const content = 'A'.repeat(200_000); - setMessagesAttribute(span, [{ role: 'user', content }], true); - const result = [{ role: 'user', content: 'A'.repeat(19970) }]; + setMessagesAttribute(span, [{ role: 'user', content }]); expect(mock.attributes).toStrictEqual({ - 'gen_ai.input.messages': JSON.stringify(result), + 'gen_ai.input.messages': JSON.stringify([{ role: 'user', content }]), }); }); - it('sets length to 1 for non-array input', () => { - setMessagesAttribute(span, { content: 'hello, world' }, true); + it('serializes non-array input as-is', () => { + setMessagesAttribute(span, { content: 'hello, world' }); expect(mock.attributes).toStrictEqual({ 'gen_ai.input.messages': '{"content":"hello, world"}', }); }); it('ignores empty array', () => { - setMessagesAttribute(span, [], true); + setMessagesAttribute(span, []); expect(mock.attributes).toStrictEqual({ 'gen_ai.input.messages': '{"content":"hello, world"}', }); diff --git a/packages/vercel-edge/src/integrations/tracing/vercelai.ts b/packages/vercel-edge/src/integrations/tracing/vercelai.ts index 69932855b4a6..8cd722063d02 100644 --- a/packages/vercel-edge/src/integrations/tracing/vercelai.ts +++ b/packages/vercel-edge/src/integrations/tracing/vercelai.ts @@ -15,12 +15,6 @@ import { addVercelAiProcessors } from '@sentry/server-utils/no-diagnostic-channe const INTEGRATION_NAME = 'VercelAI' as const; interface VercelAiOptions { - /** - * Enable or disable truncation of recorded input messages. - * Defaults to `true`. - */ - enableTruncation?: boolean; - // `recordInputs`/`recordOutputs` are intentionally omitted: this entrypoint only post-processes // spans the AI SDK already emitted (no OTel patch or tracing channel in the edge runtime), so it // cannot decide whether inputs/outputs are recorded. Control this per call via From 3aff7e6a48b8d9e473eba198ac8702422e8051f8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 10:50:03 +0200 Subject: [PATCH 2/8] ref(server-utils): Remove gen_ai inline media stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media stripping was the last piece of gen_ai input processing left after truncation removal. Since gen_ai spans always use the v2 span path (not subject to transaction payload-size limits), full input messages — including inline media (base64 images, audio, files) — are now recorded verbatim. - Delete `mediaStripping.ts` and the `getGenAiMessagesJsonString` helper; call sites now use `stringify` from `@sentry/core` directly. - Collapse langchain's `normalizeContent` into `stringify` now that it no longer strips media. - Remove media-stripping unit tests and the anthropic/openai media scenarios; the node integration suites already assert the full output format. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anthropic/scenario-media-stripping.mjs | 70 --- .../suites/tracing/anthropic/test.ts | 49 -- .../suites/tracing/openai/scenario-vision.mjs | 101 ---- .../suites/tracing/openai/test.ts | 54 -- .../server-utils/src/ai/anthropic-ai/utils.ts | 6 +- .../src/ai/core/mediaStripping.ts | 272 ---------- packages/server-utils/src/ai/core/utils.ts | 22 - .../server-utils/src/ai/google-genai/index.ts | 3 +- .../server-utils/src/ai/langchain/utils.ts | 45 +- packages/server-utils/src/ai/openai/index.ts | 3 +- .../server-utils/src/ai/vercel-ai/utils.ts | 7 +- .../server-utils/src/ai/workers-ai/utils.ts | 4 +- .../src/vercel-ai/vercel-ai-dc-subscriber.ts | 3 +- .../ai/lib/tracing/ai-media-stripping.test.ts | 470 ------------------ .../ai/lib/tracing/langchain-utils.test.ts | 186 ------- .../vercel-ai-request-messages.test.ts | 17 +- 16 files changed, 25 insertions(+), 1287 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-vision.mjs delete mode 100644 packages/server-utils/src/ai/core/mediaStripping.ts delete mode 100644 packages/server-utils/test/ai/lib/tracing/ai-media-stripping.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs deleted file mode 100644 index 92f609875dc8..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-stripping.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/anthropic/v1/messages', (req, res) => { - res.send({ - id: 'msg-truncation-test', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'This is the number **3**.' }], - model: req.body.model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 15 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new Anthropic({ - apiKey: 'mock-api-key', - baseURL: `http://localhost:${server.address().port}/anthropic`, - }); - - // Send the image showing the number 3 - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 1024, - messages: [ - { - role: 'user', - content: 'what number is this?', - }, - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'base64-mumbo-jumbo'.repeat(100), - }, - }, - ], - }, - ], - temperature: 0.7, - }); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 16d6b5779d64..222366d68ea1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -592,55 +592,6 @@ describe('Anthropic integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-media-stripping.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { - test('strips media attachment, keeping all other messages and details', async () => { - const expectedMediaMessages = JSON.stringify([ - { - role: 'user', - content: 'what number is this?', - }, - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: '[Blob substitute]', - }, - }, - ], - }, - ]); - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - - // messages.create with media attachment — image data replaced, all other messages/fields preserved - expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe(expectedMediaMessages); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); - expect(firstSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); - expect(firstSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); - expect(firstSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic'); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); - }, - }) - .start() - .completed(); - }); - }); - createEsmAndCjsTests( __dirname, 'scenario-system-instructions.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-vision.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-vision.mjs deleted file mode 100644 index 00dd173f7b49..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-vision.mjs +++ /dev/null @@ -1,101 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import OpenAI from 'openai'; - -function startMockServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/openai/chat/completions', (req, res) => { - res.send({ - id: 'chatcmpl-vision-123', - object: 'chat.completion', - created: 1677652288, - model: req.body.model, - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'I see a red square in the image.', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 50, - completion_tokens: 10, - total_tokens: 60, - }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -// Small 10x10 red PNG image encoded as base64 -const RED_PNG_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=='; - -async function run() { - const server = await startMockServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new OpenAI({ - baseURL: `http://localhost:${server.address().port}/openai`, - apiKey: 'mock-api-key', - }); - - // Vision request with inline base64 image - await client.chat.completions.create({ - model: 'gpt-4o', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${RED_PNG_BASE64}`, - }, - }, - ], - }, - ], - }); - - // Vision request with multiple images (one inline, one URL) - await client.chat.completions.create({ - model: 'gpt-4o', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Compare these images' }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${RED_PNG_BASE64}`, - }, - }, - { - type: 'image_url', - image_url: { - url: 'https://example.com/image.png', - }, - }, - ], - }, - ], - }); - }); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index a616dc524f0f..6e9528af413c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1366,60 +1366,6 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { - test('redacts inline base64 image data in vision requests', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - - // Both vision request spans should contain [Blob substitute] - for (const span of container.items) { - expect(span!.name).toBe('chat gpt-4o'); - expect(span!.status).toBe('ok'); - expect(span!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' }); - expect(span!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ type: 'string', value: 'gpt-4o' }); - expect(span!.attributes[GEN_AI_INPUT_MESSAGES].value).toContain('[Blob substitute]'); - } - }, - }) - .start() - .completed(); - }); - - test('preserves regular URLs in image_url (does not redact https links)', async () => { - await createRunner() - .ignore('event') - .expect({ - transaction: { - transaction: 'main', - }, - }) - .expect({ - span: container => { - expect(container.items).toHaveLength(2); - const multipleImagesSpan = container.items.find(span => - getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes( - 'https://example.com/image.png', - ), - ); - expect(multipleImagesSpan).toBeDefined(); - expect(multipleImagesSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toContain( - 'https://example.com/image.png', - ); - }, - }) - .start() - .completed(); - }); - }); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { test('records full gen_ai input messages when span streaming is enabled', async () => { const longContent = 'A'.repeat(50_000); diff --git a/packages/server-utils/src/ai/anthropic-ai/utils.ts b/packages/server-utils/src/ai/anthropic-ai/utils.ts index e7aede34822c..15b88cda494a 100644 --- a/packages/server-utils/src/ai/anthropic-ai/utils.ts +++ b/packages/server-utils/src/ai/anthropic-ai/utils.ts @@ -1,7 +1,7 @@ -import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; +import { captureException, SPAN_STATUS_ERROR, stringify } from '@sentry/core'; import type { Span, SpanStatusType } from '@sentry/core'; import { GEN_AI_INPUT_MESSAGES, GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conventions/attributes'; -import { extractSystemInstructions, getGenAiMessagesJsonString } from '../core/utils'; +import { extractSystemInstructions } from '../core/utils'; import type { AnthropicAiResponse } from './types'; /** @@ -21,7 +21,7 @@ export function setMessagesAttribute(span: Span, messages: unknown): void { } span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: getGenAiMessagesJsonString(filteredMessages), + [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), }); } diff --git a/packages/server-utils/src/ai/core/mediaStripping.ts b/packages/server-utils/src/ai/core/mediaStripping.ts deleted file mode 100644 index 7f0988bd5e94..000000000000 --- a/packages/server-utils/src/ai/core/mediaStripping.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Inline media content source, with a potentially very large base64 - * blob or data: uri. - */ -export type ContentMedia = Record & - ( - | { - media_type: string; - data: string; - } - | { - image_url: `data:${string}`; - } - | { - image_url: { url: `data:${string}` }; - } - | { - type: 'blob' | 'base64'; - content: string; - } - | { - b64_json: string; - } - | { - uri: `data:${string}`; - } - | { - type: 'input_audio'; - input_audio: { data: string }; - } - | { - type: 'file'; - file: { file_data?: string }; - } - ); - -/** - * Check if a content part is an OpenAI/Anthropic media source - */ -export function isContentMedia(part: unknown): part is ContentMedia { - if (!part || typeof part !== 'object') return false; - - return ( - isContentMediaSource(part) || - hasInlineData(part) || - hasImageUrl(part) || - hasInputAudio(part) || - hasFileData(part) || - hasMediaTypeData(part) || - hasVercelFileData(part) || - hasVercelImageData(part) || - hasBlobOrBase64Type(part) || - hasB64Json(part) || - hasImageGenerationResult(part) || - hasDataUri(part) - ); -} - -function hasImageUrl(part: NonNullable): boolean { - if (!('image_url' in part)) return false; - if (typeof part.image_url === 'string') return part.image_url.startsWith('data:'); - return hasNestedImageUrl(part); -} - -function hasNestedImageUrl(part: NonNullable): part is { image_url: { url: string } } { - return ( - 'image_url' in part && - !!part.image_url && - typeof part.image_url === 'object' && - 'url' in part.image_url && - typeof part.image_url.url === 'string' && - part.image_url.url.startsWith('data:') - ); -} - -function isContentMediaSource(part: NonNullable): boolean { - return 'type' in part && typeof part.type === 'string' && 'source' in part && isContentMedia(part.source); -} - -function hasInlineData(part: NonNullable): part is { inlineData: { data?: string } } { - return ( - 'inlineData' in part && - !!part.inlineData && - typeof part.inlineData === 'object' && - 'data' in part.inlineData && - typeof part.inlineData.data === 'string' - ); -} - -function hasInputAudio(part: NonNullable): part is { type: 'input_audio'; input_audio: { data: string } } { - return ( - 'type' in part && - part.type === 'input_audio' && - 'input_audio' in part && - !!part.input_audio && - typeof part.input_audio === 'object' && - 'data' in part.input_audio && - typeof part.input_audio.data === 'string' - ); -} - -function hasFileData(part: NonNullable): part is { type: 'file'; file: { file_data: string } } { - return ( - 'type' in part && - part.type === 'file' && - 'file' in part && - !!part.file && - typeof part.file === 'object' && - 'file_data' in part.file && - typeof part.file.file_data === 'string' - ); -} - -function hasMediaTypeData(part: NonNullable): part is { media_type: string; data: string } { - return 'media_type' in part && typeof part.media_type === 'string' && 'data' in part; -} - -/** - * Check for Vercel AI SDK file format: { type: "file", mediaType: "...", data: "..." } - * Only matches base64/binary data, not HTTP/HTTPS URLs (which should be preserved). - */ -function hasVercelFileData(part: NonNullable): part is { type: 'file'; mediaType: string; data: string } { - return ( - 'type' in part && - part.type === 'file' && - 'mediaType' in part && - typeof part.mediaType === 'string' && - 'data' in part && - typeof part.data === 'string' && - // Only strip base64/binary data, not HTTP/HTTPS URLs which should be preserved as references - !part.data.startsWith('http://') && - !part.data.startsWith('https://') - ); -} - -/** - * Check for Vercel AI SDK image format: { type: "image", image: "base64...", mimeType?: "..." } - * Only matches base64/data URIs, not HTTP/HTTPS URLs (which should be preserved). - * Note: mimeType is optional in Vercel AI SDK image parts. - */ -function hasVercelImageData(part: NonNullable): part is { type: 'image'; image: string; mimeType?: string } { - return ( - 'type' in part && - part.type === 'image' && - 'image' in part && - typeof part.image === 'string' && - // Only strip base64/data URIs, not HTTP/HTTPS URLs which should be preserved as references - !part.image.startsWith('http://') && - !part.image.startsWith('https://') - ); -} - -function hasBlobOrBase64Type(part: NonNullable): part is { type: 'blob' | 'base64'; content: string } { - return 'type' in part && (part.type === 'blob' || part.type === 'base64'); -} - -function hasB64Json(part: NonNullable): part is { b64_json: string } { - return 'b64_json' in part; -} - -function hasImageGenerationResult(part: NonNullable): part is { type: 'image_generation'; result: string } { - return 'type' in part && 'result' in part && part.type === 'image_generation'; -} - -function hasDataUri(part: NonNullable): part is { uri: string } { - return 'uri' in part && typeof part.uri === 'string' && part.uri.startsWith('data:'); -} - -const REMOVED_STRING = '[Blob substitute]'; - -const MEDIA_FIELDS = ['image_url', 'data', 'content', 'b64_json', 'result', 'uri', 'image'] as const; - -/** - * Replace inline binary data in a single media content part with a placeholder. - */ -export function stripInlineMediaFromSingleMessage(part: ContentMedia): ContentMedia { - const strip = { ...part }; - if (isContentMedia(strip.source)) { - strip.source = stripInlineMediaFromSingleMessage(strip.source); - } - if (hasInlineData(part)) { - strip.inlineData = { ...part.inlineData, data: REMOVED_STRING }; - } - if (hasNestedImageUrl(part)) { - strip.image_url = { ...part.image_url, url: REMOVED_STRING }; - } - if (hasInputAudio(part)) { - strip.input_audio = { ...part.input_audio, data: REMOVED_STRING }; - } - if (hasFileData(part)) { - strip.file = { ...part.file, file_data: REMOVED_STRING }; - } - for (const field of MEDIA_FIELDS) { - if (typeof strip[field] === 'string') strip[field] = REMOVED_STRING; - } - return strip; -} - -/** - * Message with the OpenAI/Anthropic `content: [...]` array format. - */ -type ContentArrayMessage = { - [key: string]: unknown; - content: unknown[]; -}; - -/** - * Message with the Google GenAI `parts: [...]` format. - */ -type PartsMessage = { - [key: string]: unknown; - parts: unknown[]; -}; - -/** - * Check if a message has the OpenAI/Anthropic content array format. - */ -function isContentArrayMessage(message: unknown): message is ContentArrayMessage { - return message !== null && typeof message === 'object' && 'content' in message && Array.isArray(message.content); -} - -/** - * Check if a message has the Google GenAI parts format. - */ -function isPartsMessage(message: unknown): message is PartsMessage { - return ( - message !== null && - typeof message === 'object' && - 'parts' in message && - Array.isArray((message as PartsMessage).parts) && - (message as PartsMessage).parts.length > 0 - ); -} - -/** - * Strip inline media from an array of messages, returning a new array. - * - * This does NOT mutate the input, because the actual API/client still needs the real media. - * Recurses into OpenAI/Anthropic `content: [...]` arrays and Google GenAI `parts: [...]`, replacing - * inline binary/base64 data with a placeholder while preserving all other structure. - */ -export function stripInlineMediaFromMessages(messages: unknown[]): unknown[] { - return messages.map(message => { - let newMessage: Record | undefined = undefined; - if (!!message && typeof message === 'object') { - if (isContentArrayMessage(message)) { - newMessage = { - ...message, - content: stripInlineMediaFromMessages(message.content), - }; - } else if ('content' in message && isContentMedia(message.content)) { - newMessage = { - ...message, - content: stripInlineMediaFromSingleMessage(message.content), - }; - } - if (isPartsMessage(message)) { - newMessage = { - // might have to strip content AND parts - ...(newMessage ?? message), - parts: stripInlineMediaFromMessages(message.parts), - }; - } - if (isContentMedia(newMessage)) { - newMessage = stripInlineMediaFromSingleMessage(newMessage); - } else if (isContentMedia(message)) { - newMessage = stripInlineMediaFromSingleMessage(message); - } - } - return newMessage ?? message; - }); -} diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts index aab6a0214eb8..06f39c36b021 100644 --- a/packages/server-utils/src/ai/core/utils.ts +++ b/packages/server-utils/src/ai/core/utils.ts @@ -15,7 +15,6 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; -import { stripInlineMediaFromMessages } from './mediaStripping'; export interface AIRecordingOptions { recordInputs?: boolean; @@ -168,27 +167,6 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp span.end(); } -/** - * Serialize a string, an array of messages, or an object to a JSON string for span attributes, - * stripping inline media (base64 blobs, data URIs, etc.) from message arrays so large binary - * payloads never end up in span attributes. - * - * @param value - The value to serialize - * @returns The JSON string - */ -export function getGenAiMessagesJsonString(value: T | T[]): string { - if (typeof value === 'string') { - // Some values are already JSON strings, so we don't need to duplicate the JSON parsing - return value; - } - // Media stripping recurses the value and `JSON.stringify` can throw on circular refs or - // non-serializable values (e.g. BigInt); never let that crash instrumentation. - try { - return JSON.stringify(Array.isArray(value) ? stripInlineMediaFromMessages(value) : value); - } catch { - return '[unserializable]'; - } -} /** * Extract system instructions from messages array. diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index b244036c29d4..9f021947b28e 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -35,7 +35,6 @@ import type { InstrumentedMethodEntry } from '../core/utils'; import { buildMethodPath, extractSystemInstructions, - getGenAiMessagesJsonString, resolveAIRecordingOptions, } from '../core/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; @@ -187,7 +186,7 @@ export function addPrivateRequestAttributes(span: Span, params: Record "Hello" - * - * // Multimodal array content — media is replaced with "[Blob substitute]" before JSON.stringify: - * normalizeContent([ - * { type: "text", text: "What color?" }, - * { type: "image_url", image_url: { url: "data:image/png;base64,iVBOR..." } } - * ]) - * // => '[{"type":"text","text":"What color?"},{"type":"image_url","image_url":{"url":"[Blob substitute]"}}]' - * - * // Without this, stringification would JSON.stringify the raw array and the base64 blob - * // would end up in span attributes, since downstream stripping only works on objects. - */ -function normalizeContent(v: unknown): string | undefined { - if (Array.isArray(v)) { - try { - const stripped = v.map(part => - part && typeof part === 'object' && isContentMedia(part) ? stripInlineMediaFromSingleMessage(part) : part, - ); - return JSON.stringify(stripped); - } catch { - return String(v); - } - } - return stringify(v, String); -} - /** * Normalizes a single role token to our canonical set. * @@ -149,7 +116,7 @@ export function normalizeLangChainMessages( const messageType = maybeGetType.call(message); return { role: normalizeMessageRole(messageType), - content: normalizeContent(message.content), + content: stringify(message.content, String), }; } @@ -162,7 +129,7 @@ export function normalizeLangChainMessages( return { role: normalizeMessageRole(role), - content: normalizeContent(message.kwargs?.content), + content: stringify(message.kwargs?.content, String), }; } @@ -171,7 +138,7 @@ export function normalizeLangChainMessages( const role = String(message.type).toLowerCase(); return { role: normalizeMessageRole(role), - content: normalizeContent(message.content), + content: stringify(message.content, String), }; } @@ -180,7 +147,7 @@ export function normalizeLangChainMessages( if (message.role) { return { role: normalizeMessageRole(String(message.role)), - content: normalizeContent(message.content), + content: stringify(message.content, String), }; } @@ -190,14 +157,14 @@ export function normalizeLangChainMessages( if (ctor && ctor !== 'Object') { return { role: normalizeMessageRole(normalizeRoleNameFromCtor(ctor)), - content: normalizeContent(message.content), + content: stringify(message.content, String), }; } // 6) Fallback: treat as user text return { role: 'user', - content: normalizeContent(message.content), + content: stringify(message.content, String), }; }); } diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index 076c2684c90a..cca0dc897a15 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -23,7 +23,6 @@ import type { InstrumentedMethodEntry } from '../core/utils'; import { buildMethodPath, extractSystemInstructions, - getGenAiMessagesJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, } from '../core/utils'; @@ -122,7 +121,7 @@ export function addRequestAttributes(span: Span, params: Record span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - span.setAttribute(GEN_AI_INPUT_MESSAGES, getGenAiMessagesJsonString(filteredMessages)); + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(filteredMessages)); } /** diff --git a/packages/server-utils/src/ai/vercel-ai/utils.ts b/packages/server-utils/src/ai/vercel-ai/utils.ts index 6dc3b5811636..ffa0825cfad5 100644 --- a/packages/server-utils/src/ai/vercel-ai/utils.ts +++ b/packages/server-utils/src/ai/vercel-ai/utils.ts @@ -1,4 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated */ +import { stringify } from '@sentry/core'; import type { Span, SpanAttributes, SpanJSON, TraceContext } from '@sentry/core'; import { GEN_AI_INPUT_MESSAGES, @@ -9,7 +10,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, } from '@sentry/conventions/attributes'; -import { extractSystemInstructions, getGenAiMessagesJsonString } from '../core/utils'; +import { extractSystemInstructions } from '../core/utils'; import { toolCallSpanContextMap } from './constants'; import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; @@ -239,7 +240,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - const messagesJson = getGenAiMessagesJsonString(filteredMessages); + const messagesJson = stringify(filteredMessages); span.setAttributes({ [AI_PROMPT_ATTRIBUTE]: messagesJson, @@ -259,7 +260,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - const messagesJson = getGenAiMessagesJsonString(filteredMessages); + const messagesJson = stringify(filteredMessages); span.setAttributes({ [AI_PROMPT_MESSAGES_ATTRIBUTE]: messagesJson, diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 8e95a7585cf6..5e68cafacc2c 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -19,7 +19,7 @@ import { import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { extractSystemInstructions, getGenAiMessagesJsonString, setTokenUsageAttributes } from '../core/utils'; +import { extractSystemInstructions, setTokenUsageAttributes } from '../core/utils'; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; import type { WorkersAiInput, WorkersAiOutput } from './types'; @@ -117,7 +117,7 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); } - span.setAttribute(GEN_AI_INPUT_MESSAGES, getGenAiMessagesJsonString(filteredMessages)); + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(filteredMessages)); } /** diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts index 4dabb3fb4dbd..8e1f65c35934 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts @@ -38,7 +38,6 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { getProviderMetadataAttributes } from '../ai/vercel-ai'; -import { getGenAiMessagesJsonString } from '../ai/core/utils'; import { WORKERS_AI_INTEGRATION_NAME } from '../ai/workers-ai/constants'; import { bindTracingChannelToSpan } from '../tracing-channel'; import { asNumber, asString, isReadableStream, type StreamedModelCallResult, sum, tapModelCallStream } from './util'; @@ -726,7 +725,7 @@ function buildInputMessageAttributes(event: Record): Record { - describe('stripInlineMediaFromMessages', () => { - it('leaves empty/non-array/small messages alone', () => { - expect(stripInlineMediaFromMessages([])).toStrictEqual([]); - expect(stripInlineMediaFromMessages([{ text: 'hello' }])).toStrictEqual([{ text: 'hello' }]); - }); - - it('strips inline media from messages, keeping all messages', () => { - const b64 = Buffer.from('lots of data\n').toString('base64'); - const removed = '[Blob substitute]'; - const messages = [ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: b64, - }, - }, - ], - }, - { - role: 'user', - content: { - image_url: `data:image/png;base64,${b64}`, - }, - }, - { - role: 'agent', - type: 'image', - content: { - b64_json: b64, - }, - }, - { - role: 'system', - inlineData: { - mimeType: 'kiki/booba', - data: 'booboobooboobooba', - }, - content: [ - 'this one has content AND parts and has inline data', - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: b64, - }, - }, - ], - parts: [ - { - inlineData: { - mimeType: 'image/png', - data: 'bloobloobloo', - }, - }, - { - image_url: `data:image/png;base64,${b64}`, - }, - { - type: 'image_generation', - result: b64, - }, - { - uri: `data:image/png;base64,${b64}`, - mediaType: 'image/png', - }, - { - type: 'blob', - mediaType: 'image/png', - content: b64, - }, - { - type: 'text', - text: 'just some text!', - }, - 'unadorned text', - ], - }, - ]; - - // indented json makes for better diffs in test output - const messagesJson = JSON.stringify(messages, null, 2); - const result = stripInlineMediaFromMessages(messages); - - // original messages objects must not be mutated - expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - // all messages are kept, with inline media stripped - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: removed, - }, - }, - ], - }, - { - role: 'user', - content: { - image_url: removed, - }, - }, - { - role: 'agent', - type: 'image', - content: { - b64_json: removed, - }, - }, - { - role: 'system', - inlineData: { - mimeType: 'kiki/booba', - data: removed, - }, - content: [ - 'this one has content AND parts and has inline data', - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: removed, - }, - }, - ], - parts: [ - { - inlineData: { - mimeType: 'image/png', - data: removed, - }, - }, - { - image_url: removed, - }, - { - type: 'image_generation', - result: removed, - }, - { - uri: removed, - mediaType: 'image/png', - }, - { - type: 'blob', - mediaType: 'image/png', - content: removed, - }, - { - type: 'text', - text: 'just some text!', - }, - 'unadorned text', - ], - }, - ]); - }); - - it('strips OpenAI vision format with nested image_url object', () => { - const b64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8/5+hnoEIwDiqkL4KAQBf9AoL/k2KLAAAAABJRU5ErkJggg=='; - const removed = '[Blob substitute]'; - - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${b64}`, - }, - }, - ], - }, - ]; - - const messagesJson = JSON.stringify(messages, null, 2); - const result = stripInlineMediaFromMessages(messages); - - // original messages must not be mutated - expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { - type: 'image_url', - image_url: { - url: removed, - }, - }, - ], - }, - ]); - - // Validate no raw base64 leaks - const serialized = JSON.stringify(result); - expect(serialized).not.toMatch(/[A-Za-z0-9+/]{100,}={0,2}/); - expect(serialized).toContain('[Blob substitute]'); - }); - - it('does not redact image_url with regular URL (non-data: scheme)', () => { - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { - type: 'image_url', - image_url: { - url: 'https://example.com/image.png', - }, - }, - ], - }, - ]; - - const result = stripInlineMediaFromMessages(messages); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'What is in this image?' }, - { - type: 'image_url', - image_url: { - url: 'https://example.com/image.png', - }, - }, - ], - }, - ]); - }); - - it('strips multiple image parts in a single message', () => { - const b64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8/5+hnoEIwDiqkL4KAQBf9AoL/k2KLAAAAABJRU5ErkJggg=='; - const removed = '[Blob substitute]'; - - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Compare these images' }, - { - type: 'image_url', - image_url: { url: `data:image/png;base64,${b64}` }, - }, - { - type: 'image_url', - image_url: { url: `data:image/jpeg;base64,${b64}` }, - }, - { - type: 'image_url', - image_url: { url: 'https://example.com/safe.png' }, - }, - ], - }, - ]; - - const result = stripInlineMediaFromMessages(messages); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Compare these images' }, - { - type: 'image_url', - image_url: { url: removed }, - }, - { - type: 'image_url', - image_url: { url: removed }, - }, - { - type: 'image_url', - image_url: { url: 'https://example.com/safe.png' }, - }, - ], - }, - ]); - }); - - it('strips input_audio data from messages', () => { - const b64Audio = Buffer.from('fake audio data for testing').toString('base64'); - const removed = '[Blob substitute]'; - - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'What does this audio say?' }, - { - type: 'input_audio', - input_audio: { - data: b64Audio, - format: 'wav', - }, - }, - ], - }, - ]; - - const messagesJson = JSON.stringify(messages, null, 2); - const result = stripInlineMediaFromMessages(messages); - - expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'What does this audio say?' }, - { - type: 'input_audio', - input_audio: { - data: removed, - format: 'wav', - }, - }, - ], - }, - ]); - - const serialized = JSON.stringify(result); - expect(serialized).not.toContain(b64Audio); - expect(serialized).toContain(removed); - }); - - it('strips file_data from file content parts', () => { - const b64File = Buffer.from('fake file content for testing').toString('base64'); - const removed = '[Blob substitute]'; - - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Summarize this document' }, - { - type: 'file', - file: { - file_data: b64File, - filename: 'document.pdf', - }, - }, - ], - }, - ]; - - const messagesJson = JSON.stringify(messages, null, 2); - const result = stripInlineMediaFromMessages(messages); - - expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Summarize this document' }, - { - type: 'file', - file: { - file_data: removed, - filename: 'document.pdf', - }, - }, - ], - }, - ]); - - const serialized = JSON.stringify(result); - expect(serialized).not.toContain(b64File); - expect(serialized).toContain(removed); - }); - - it('does not redact file parts that only have file_id (no inline data)', () => { - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Summarize this document' }, - { - type: 'file', - file: { - file_id: 'file-abc123', - filename: 'document.pdf', - }, - }, - ], - }, - ]; - - const result = stripInlineMediaFromMessages(messages); - - expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Summarize this document' }, - { - type: 'file', - file: { - file_id: 'file-abc123', - filename: 'document.pdf', - }, - }, - ], - }, - ]); - }); - }); - - describe('getGenAiMessagesJsonString', () => { - it('returns a fallback instead of throwing on circular references', () => { - const circular: Record = { role: 'user', content: 'hi' }; - circular.self = circular; - - expect(getGenAiMessagesJsonString(circular)).toBe('[unserializable]'); - expect(getGenAiMessagesJsonString([circular])).toBe('[unserializable]'); - }); - - it('returns strings as-is and serializes objects', () => { - expect(getGenAiMessagesJsonString('hello')).toBe('hello'); - expect(getGenAiMessagesJsonString({ a: 1 })).toBe('{"a":1}'); - }); - - it('strips inline media from message arrays while keeping all messages', () => { - const b64 = Buffer.from('lots of data\n').toString('base64'); - const messages = [ - { role: 'user', content: 'first message' }, - { - role: 'user', - content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }], - }, - ]; - - const result = getGenAiMessagesJsonString(messages); - - expect(result).toBe( - JSON.stringify([ - { role: 'user', content: 'first message' }, - { role: 'user', content: [{ type: 'image_url', image_url: { url: '[Blob substitute]' } }] }, - ]), - ); - expect(result).not.toContain(b64); - }); - }); -}); diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts index 0bccc3302019..b0c6e3d16eed 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-utils.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { GEN_AI_INPUT_MESSAGES } from '@sentry/conventions/attributes'; import type { LangChainMessage } from '../../../../src/ai/langchain/types'; import { _INTERNAL_mergeLangChainCallbackHandler, - extractChatModelRequestAttributes, normalizeLangChainMessages, } from '../../../../src/ai/langchain/utils'; @@ -65,190 +63,6 @@ describe('normalizeLangChainMessages', () => { const result = normalizeLangChainMessages(messages); expect(result).toEqual([{ role: 'user', content: 'Hello from serialized' }]); }); - - describe('multimodal content media stripping', () => { - const b64Data = `iVBORw0KGgoAAAANSUhEUgAAAAUA${'A'.repeat(200)}`; - const BLOB_SUBSTITUTE = '[Blob substitute]'; - - it('strips base64 image_url from multimodal array content via _getType()', () => { - const messages = [ - { - _getType: () => 'human', - content: [ - { type: 'text', text: 'What color is in this image?' }, - { type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } }, - ], - }, - ] as unknown as LangChainMessage[]; - - const result = normalizeLangChainMessages(messages); - expect(result).toHaveLength(1); - expect(result[0]!.role).toBe('user'); - - const parsed = JSON.parse(result[0]!.content); - expect(parsed).toHaveLength(2); - expect(parsed[0]).toEqual({ type: 'text', text: 'What color is in this image?' }); - expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('strips base64 data from Anthropic-style source blocks', () => { - const messages = [ - { - _getType: () => 'human', - content: [ - { type: 'text', text: 'Describe this image' }, - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: b64Data, - }, - }, - ], - }, - ] as unknown as LangChainMessage[]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].source.data).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('strips base64 from inline_data (Google GenAI style)', () => { - const messages: LangChainMessage[] = [ - { - type: 'human', - content: [ - { type: 'text', text: 'Describe' }, - { inlineData: { mimeType: 'image/png', data: b64Data } }, - ] as unknown as string, - }, - ]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].inlineData.data).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('strips base64 from input_audio content parts', () => { - const messages = [ - { - _getType: () => 'human', - content: [ - { type: 'text', text: 'What do you hear?' }, - { type: 'input_audio', input_audio: { data: b64Data } }, - ], - }, - ] as unknown as LangChainMessage[]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].input_audio.data).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('preserves text-only array content without modification', () => { - const messages = [ - { - _getType: () => 'human', - content: [ - { type: 'text', text: 'First part' }, - { type: 'text', text: 'Second part' }, - ], - }, - ] as unknown as LangChainMessage[]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed).toEqual([ - { type: 'text', text: 'First part' }, - { type: 'text', text: 'Second part' }, - ]); - }); - - it('strips media from serialized LangChain format with array content', () => { - const messages: LangChainMessage[] = [ - { - lc: 1, - id: ['langchain_core', 'messages', 'HumanMessage'], - kwargs: { - content: [ - { type: 'text', text: 'Describe this' }, - { type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } }, - ] as unknown as string, - }, - }, - ]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('strips media from messages with role property and array content', () => { - const messages: LangChainMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Look at this' }, - { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${b64Data}` } }, - ] as unknown as string, - }, - ]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE); - expect(result[0]!.content).not.toContain(b64Data); - }); - - it('strips media from messages with type property and array content', () => { - const messages: LangChainMessage[] = [ - { - type: 'human', - content: [ - { type: 'text', text: 'Check this' }, - { type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } }, - ] as unknown as string, - }, - ]; - - const result = normalizeLangChainMessages(messages); - const parsed = JSON.parse(result[0]!.content); - expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE); - }); - }); -}); - -describe('extractChatModelRequestAttributes with multimodal content', () => { - const b64Data = `iVBORw0KGgoAAAANSUhEUgAAAAUA${'A'.repeat(200)}`; - - it('strips base64 from input messages attribute', () => { - const serialized = { id: ['langchain', 'chat_models', 'openai'], name: 'ChatOpenAI' }; - const messages: LangChainMessage[][] = [ - [ - { - _getType: () => 'human', - content: [ - { type: 'text', text: 'What is in this image?' }, - { type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } }, - ], - } as unknown as LangChainMessage, - ], - ]; - - const attrs = extractChatModelRequestAttributes(serialized, messages, true); - const inputMessages = attrs[GEN_AI_INPUT_MESSAGES] as string | undefined; - - expect(inputMessages).toBeDefined(); - expect(inputMessages).not.toContain(b64Data); - expect(inputMessages).toContain('[Blob substitute]'); - expect(inputMessages).toContain('What is in this image?'); - }); }); describe('_INTERNAL_mergeLangChainCallbackHandler', () => { diff --git a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts index 82c41bb978ce..10cc5491bfa2 100644 --- a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getGenAiMessagesJsonString } from '../../../../src/ai/core/utils'; +import { stringify } from '@sentry/core'; import { GEN_AI_INPUT_MESSAGES, GEN_AI_SYSTEM_INSTRUCTIONS } from '@sentry/conventions/attributes'; import { requestMessagesFromPrompt } from '../../../../src/ai/vercel-ai/utils'; import { AI_PROMPT_MESSAGES_ATTRIBUTE } from '../../../../src/ai/vercel-ai/vercel-ai-attributes'; @@ -31,8 +31,8 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { requestMessagesFromPrompt(span, attributes); - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getGenAiMessagesJsonString(messages)); - expect(recorded[GEN_AI_INPUT_MESSAGES]).toBe(getGenAiMessagesJsonString(messages)); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify(messages)); + expect(recorded[GEN_AI_INPUT_MESSAGES]).toBe(stringify(messages)); expect(recorded[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined(); }); @@ -49,13 +49,11 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { expect(recorded[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe(JSON.stringify([{ type: 'text', content: 'be nice' }])); // System message removed; output is just the remainder. - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe( - getGenAiMessagesJsonString([{ role: 'user', content: 'hello' }]), - ); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify([{ role: 'user', content: 'hello' }])); expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).not.toBe(original); }); - it('keeps all messages and strips inline media', () => { + it('keeps all messages including inline media', () => { const { span, recorded } = createRecordingSpan(); const b64 = Buffer.from('lots of data\n').toString('base64'); @@ -67,10 +65,9 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { requestMessagesFromPrompt(span, attributes); - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getGenAiMessagesJsonString(messages)); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify(messages)); expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain('first'); - expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain('[Blob substitute]'); - expect(recorded[GEN_AI_INPUT_MESSAGES]).not.toContain(b64); + expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain(b64); }); it('does not throw and sets no attributes for malformed JSON', () => { From ac60a41f9a42420df770291c8b6ebee3c75e4cee Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 11:14:56 +0200 Subject: [PATCH 3/8] yarn fix --- packages/server-utils/src/ai/core/utils.ts | 1 - packages/server-utils/src/ai/google-genai/index.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts index 06f39c36b021..3d69a32f0461 100644 --- a/packages/server-utils/src/ai/core/utils.ts +++ b/packages/server-utils/src/ai/core/utils.ts @@ -167,7 +167,6 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp span.end(); } - /** * Extract system instructions from messages array. * Finds the first system message and formats it according to OpenTelemetry semantic conventions. diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index 9f021947b28e..160e004a3536 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -32,11 +32,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import type { InstrumentedMethodEntry } from '../core/utils'; -import { - buildMethodPath, - extractSystemInstructions, - resolveAIRecordingOptions, -} from '../core/utils'; +import { buildMethodPath, extractSystemInstructions, resolveAIRecordingOptions } from '../core/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types'; From c6c02e7323ce0018490cb000258bd32ba356fabe Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 11:32:01 +0200 Subject: [PATCH 4/8] test(server-utils): Simplify gen_ai span-streaming integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-suite span-streaming tests existed only to prove input messages were recorded in full (not truncated) — firing 50k-char payloads. With no message-modifying logic left in the SDK, that assertion is redundant. Reuse each suite's basic scenario against `instrument-streaming.mjs` and assert the same core span attributes as the static-lifecycle base tests, so the `traceLifecycle: 'stream'` path stays covered without the truncation framing or giant payloads. Delete the now-unused `scenario-span-streaming.mjs` files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anthropic/scenario-span-streaming.mjs | 53 ------------ .../suites/tracing/anthropic/test.ts | 23 +++-- .../google-genai/scenario-span-streaming.mjs | 50 ----------- .../suites/tracing/google-genai/test.ts | 16 ++-- .../langchain/scenario-span-streaming.mjs | 51 ------------ .../suites/tracing/langchain/test.ts | 18 ++-- .../langgraph/scenario-span-streaming.mjs | 41 --------- .../suites/tracing/langgraph/test.ts | 17 ++-- .../openai/scenario-span-streaming.mjs | 83 ------------------- .../suites/tracing/openai/test.ts | 40 +++++---- 10 files changed, 71 insertions(+), 321 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs deleted file mode 100644 index 53594bb60058..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/anthropic/v1/messages', (req, res) => { - res.send({ - id: 'msg_streaming_test', - type: 'message', - model: req.body.model, - role: 'assistant', - content: [{ type: 'text', text: 'Response' }], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new Anthropic({ - apiKey: 'mock-api-key', - baseURL: `http://localhost:${server.address().port}/anthropic`, - }); - - // Long content that would normally be truncated - const longContent = 'A'.repeat(50_000); - await client.messages.create({ - model: 'claude-3-haiku-20240307', - max_tokens: 100, - messages: [{ role: 'user', content: longContent }], - }); - }); - - // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 222366d68ea1..63df1e254ecd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -19,7 +19,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; -import { getStringAttributeValue, isOrchestrionEnabled } from '../../../utils'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('Anthropic integration', () => { @@ -621,16 +621,25 @@ describe('Anthropic integration', () => { }, ); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('records full gen_ai input messages when span streaming is enabled', async () => { - const longContent = 'A'.repeat(50_000); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('creates anthropic related spans with span streaming enabled', async () => { await createRunner() .expect({ span: container => { - const chatSpan = container.items.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), + const completionSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_mock123', ); - expect(chatSpan).toBeDefined(); + expect(completionSpan).toBeDefined(); + expect(completionSpan!.name).toBe('chat claude-3-haiku-20240307'); + expect(completionSpan!.status).toBe('ok'); + expect(completionSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('chat'); + expect(completionSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-haiku-20240307'); + expect(completionSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(completionSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic'); + expect(completionSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); + expect(completionSpan!.attributes['sentry.origin'].value).toBe('auto.ai.anthropic'); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs deleted file mode 100644 index 5785cd07d9a0..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import { GoogleGenAI } from '@google/genai'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockGoogleGenAIServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/v1beta/models/:model\\:generateContent', (req, res) => { - res.json({ - candidates: [ - { - content: { parts: [{ text: 'Response' }], role: 'model' }, - finishReason: 'STOP', - }, - ], - usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockGoogleGenAIServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new GoogleGenAI({ - apiKey: 'mock-api-key', - httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, - }); - - const longContent = 'A'.repeat(50_000); - await client.models.generateContent({ - model: 'gemini-1.5-flash', - contents: [{ role: 'user', parts: [{ text: longContent }] }], - }); - }); - - // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 90d73e7d57e3..70f3607d444c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -21,7 +21,6 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -import { getStringAttributeValue } from '../../../utils'; const EXPECTED_ORIGIN = 'auto.ai.google_genai'; @@ -456,16 +455,19 @@ describe('Google GenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('records full gen_ai input messages when span streaming is enabled', async () => { - const longContent = 'A'.repeat(50_000); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('creates google genai related spans with span streaming enabled', async () => { await createRunner() .expect({ span: container => { - const generateContentSpan = container.items.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), - ); + const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash'); expect(generateContentSpan).toBeDefined(); + expect(generateContentSpan!.status).toBe('ok'); + expect(generateContentSpan!.attributes['sentry.op'].value).toBe('gen_ai.generate_content'); + expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); + expect(generateContentSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('google_genai'); + expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('gemini-1.5-flash'); + expect(generateContentSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs deleted file mode 100644 index e80d0c292e0c..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { ChatAnthropic } from '@langchain/anthropic'; -import * as Sentry from '@sentry/node'; -import express from 'express'; - -function startMockAnthropicServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/v1/messages', (req, res) => { - res.json({ - id: 'msg_span_streaming_test', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'Response' }], - model: req.body.model, - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 10, output_tokens: 5 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockAnthropicServer(); - const baseUrl = `http://localhost:${server.address().port}`; - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const model = new ChatAnthropic({ - model: 'claude-3-5-sonnet-20241022', - apiKey: 'mock-api-key', - clientOptions: { - baseURL: baseUrl, - }, - }); - - const longContent = 'A'.repeat(50_000); - await model.invoke([{ role: 'user', content: longContent }]); - }); - - await Sentry.flush(2000); - - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index a6541a3b3c94..cf3b7e264f17 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -22,7 +22,6 @@ import { GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; -import { getStringAttributeValue } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; import { createEsmTests } from '../../../utils/runner/createEsmAndCjsTests'; @@ -401,16 +400,19 @@ describe('LangChain integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('records full gen_ai input messages when span streaming is enabled', async () => { - const longContent = 'A'.repeat(50_000); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('creates langchain related spans with span streaming enabled', async () => { await createRunner() .expect({ span: container => { - const chatSpan = container.items.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), - ); - expect(chatSpan).toBeDefined(); + const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022'); + expect(sonnetSpan).toBeDefined(); + expect(sonnetSpan!.status).toBe('ok'); + expect(sonnetSpan!.attributes['sentry.op'].value).toBe('gen_ai.chat'); + expect(sonnetSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain'); + expect(sonnetSpan!.attributes[GEN_AI_PROVIDER_NAME].value).toBe('anthropic'); + expect(sonnetSpan!.attributes[GEN_AI_REQUEST_MODEL].value).toBe('claude-3-5-sonnet-20241022'); + expect(sonnetSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs deleted file mode 100644 index fe5ff23c10aa..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; -import * as Sentry from '@sentry/node'; - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'langgraph-test' }, async () => { - const mockLlm = () => { - return { - messages: [ - { - role: 'assistant', - content: 'Mock LLM response', - response_metadata: { - model_name: 'mock-model', - finish_reason: 'stop', - tokenUsage: { - promptTokens: 20, - completionTokens: 10, - totalTokens: 30, - }, - }, - }, - ], - }; - }; - - const graph = new StateGraph(MessagesAnnotation) - .addNode('agent', mockLlm) - .addEdge(START, 'agent') - .addEdge('agent', END) - .compile({ name: 'weather_assistant' }); - - const longContent = 'A'.repeat(50_000); - await graph.invoke({ - messages: [{ role: 'user', content: longContent }], - }); - }); - - await Sentry.flush(2000); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 9f7bcf1f33bd..031e79130f1b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -235,16 +235,21 @@ describe('LangGraph integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('records full gen_ai input messages when span streaming is enabled', async () => { - const longContent = 'A'.repeat(50_000); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('creates langgraph related spans with span streaming enabled', async () => { await createRunner() .expect({ span: container => { - const chatSpan = container.items.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), + const weatherTodaySpan = container.items.find(span => + getStringAttributeValue(span.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes( + 'What is the weather today?', + ), ); - expect(chatSpan).toBeDefined(); + expect(weatherTodaySpan).toBeDefined(); + expect(weatherTodaySpan!.name).toBe('invoke_agent weather_assistant'); + expect(weatherTodaySpan!.status).toBe('ok'); + expect(weatherTodaySpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); + expect(weatherTodaySpan!.attributes['sentry.origin'].value).toBe('auto.ai.langgraph'); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs deleted file mode 100644 index 33b8fd2e555b..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import OpenAI from 'openai'; - -function startMockServer() { - const app = express(); - app.use(express.json({ limit: '10mb' })); - - app.post('/openai/chat/completions', (req, res) => { - res.send({ - id: 'chatcmpl-mock123', - object: 'chat.completion', - created: 1677652288, - model: req.body.model, - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Hello!' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }); - }); - - app.post('/openai/responses', (req, res) => { - res.send({ - id: 'resp_mock456', - object: 'response', - created_at: 1677652290, - model: req.body.model, - output: [ - { - type: 'message', - id: 'msg_mock_output_1', - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text: 'Response text', annotations: [] }], - }, - ], - output_text: 'Response text', - status: 'completed', - usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, - }); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - resolve(server); - }); - }); -} - -async function run() { - const server = await startMockServer(); - - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - const client = new OpenAI({ - baseURL: `http://localhost:${server.address().port}/openai`, - apiKey: 'mock-api-key', - }); - - // Single long message for chat completions - const longContent = 'A'.repeat(50_000); - await client.chat.completions.create({ - model: 'gpt-4', - messages: [{ role: 'user', content: longContent }], - }); - - // Responses API with long string input - const longStringInput = 'B'.repeat(50_000); - await client.responses.create({ - model: 'gpt-4', - input: longStringInput, - }); - }); - - // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits - await Sentry.flush(); - server.close(); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 6e9528af413c..13d39b73da3d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -23,7 +23,6 @@ import { GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; -import { getStringAttributeValue } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('OpenAI integration', () => { @@ -1366,24 +1365,35 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { - test('records full gen_ai input messages when span streaming is enabled', async () => { - const longContent = 'A'.repeat(50_000); - const longStringInput = 'B'.repeat(50_000); + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('creates openai related spans with span streaming enabled', async () => { await createRunner() .expect({ span: container => { - const spans = container.items; - - const chatSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longContent), - ); - expect(chatSpan).toBeDefined(); - - const responsesSpan = spans.find(s => - getStringAttributeValue(s.attributes[GEN_AI_INPUT_MESSAGES]?.value)?.includes(longStringInput), + const chatCompletionSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123', ); - expect(responsesSpan).toBeDefined(); + expect(chatCompletionSpan).toBeDefined(); + expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); + expect(chatCompletionSpan!.status).toBe('ok'); + expect(chatCompletionSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ type: 'string', value: 'chat' }); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: 'auto.ai.openai', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL]).toEqual({ + type: 'string', + value: 'gpt-3.5-turbo', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toEqual({ + type: 'string', + value: '[{"role":"user","content":"What is the capital of France?"}]', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_PROVIDER_NAME]).toEqual({ type: 'string', value: 'openai' }); }, }) .start() From 29afb51f6ff189753ebe3c7781e205cc78270b0e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 11:37:45 +0200 Subject: [PATCH 5/8] test(server-utils): Rename instrument-streaming to instrument-span-streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `instrument-streaming.mjs` configures `traceLifecycle: 'stream'` (span streaming), which is easily confused with the `scenario-stream*` files that exercise streaming *responses* — a different concept. Rename to `instrument-span-streaming.mjs` to disambiguate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../{instrument-streaming.mjs => instrument-span-streaming.mjs} | 0 .../node-integration-tests/suites/tracing/anthropic/test.ts | 2 +- .../{instrument-streaming.mjs => instrument-span-streaming.mjs} | 0 .../node-integration-tests/suites/tracing/google-genai/test.ts | 2 +- .../{instrument-streaming.mjs => instrument-span-streaming.mjs} | 0 .../node-integration-tests/suites/tracing/langchain/test.ts | 2 +- .../{instrument-streaming.mjs => instrument-span-streaming.mjs} | 0 .../node-integration-tests/suites/tracing/langgraph/test.ts | 2 +- .../{instrument-streaming.mjs => instrument-span-streaming.mjs} | 0 .../node-integration-tests/suites/tracing/openai/test.ts | 2 +- 10 files changed, 5 insertions(+), 5 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/anthropic/{instrument-streaming.mjs => instrument-span-streaming.mjs} (100%) rename dev-packages/node-integration-tests/suites/tracing/google-genai/{instrument-streaming.mjs => instrument-span-streaming.mjs} (100%) rename dev-packages/node-integration-tests/suites/tracing/langchain/{instrument-streaming.mjs => instrument-span-streaming.mjs} (100%) rename dev-packages/node-integration-tests/suites/tracing/langgraph/{instrument-streaming.mjs => instrument-span-streaming.mjs} (100%) rename dev-packages/node-integration-tests/suites/tracing/openai/{instrument-streaming.mjs => instrument-span-streaming.mjs} (100%) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-span-streaming.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 63df1e254ecd..ac73d1f3bec3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -621,7 +621,7 @@ describe('Anthropic integration', () => { }, ); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates anthropic related spans with span streaming enabled', async () => { await createRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-span-streaming.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 70f3607d444c..b0303890ddda 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -455,7 +455,7 @@ describe('Google GenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates google genai related spans with span streaming enabled', async () => { await createRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-span-streaming.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/langchain/instrument-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index cf3b7e264f17..4ce3bca0b2d3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -400,7 +400,7 @@ describe('LangChain integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates langchain related spans with span streaming enabled', async () => { await createRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-span-streaming.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 031e79130f1b..0af7335c636f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -235,7 +235,7 @@ describe('LangGraph integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates langgraph related spans with span streaming enabled', async () => { await createRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-span-streaming.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/instrument-span-streaming.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 13d39b73da3d..717f2e726667 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1365,7 +1365,7 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates openai related spans with span streaming enabled', async () => { await createRunner() .expect({ From 935b2174d1656891f120a0585f29e76b41294661 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 11:59:10 +0200 Subject: [PATCH 6/8] test(server-utils): Drop redundant non-truncation gen_ai tests and stale comments With no message-truncation logic left in the SDK, tests that assert "we keep the full message" or use giant payloads to prove non-truncation exercise behavior that no longer exists. - Remove the duplicate "keeps all messages including inline media" test (same code path as the plain serialization test). - Reword the 200k-char "without truncation" anthropic test to a basic input messages check, drop the "non-array input" case (pure `stringify`, covered in core), and make the empty-array test self-contained. - Fix stale comments/JSDoc that still referenced truncation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/anthropic-ai/utils.ts | 2 +- packages/server-utils/src/ai/openai/index.ts | 2 +- .../server-utils/src/ai/workers-ai/utils.ts | 2 +- .../vercel-ai-request-messages.test.ts | 17 --------------- .../test/ai/lib/utils/anthropic-utils.test.ts | 21 +++++++------------ 5 files changed, 10 insertions(+), 34 deletions(-) diff --git a/packages/server-utils/src/ai/anthropic-ai/utils.ts b/packages/server-utils/src/ai/anthropic-ai/utils.ts index 15b88cda494a..9bc9a6261956 100644 --- a/packages/server-utils/src/ai/anthropic-ai/utils.ts +++ b/packages/server-utils/src/ai/anthropic-ai/utils.ts @@ -5,7 +5,7 @@ import { extractSystemInstructions } from '../core/utils'; import type { AnthropicAiResponse } from './types'; /** - * Set the input messages attribute, extracting system instructions before truncation. + * Set the input messages attribute, extracting system instructions into their own attribute. */ export function setMessagesAttribute(span: Span, messages: unknown): void { if (Array.isArray(messages) && messages.length === 0) { diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index cca0dc897a15..78d27f4a1438 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -82,7 +82,7 @@ export function extractRequestAttributes(args: unknown[], operationName: string) // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. export function addRequestAttributes(span: Span, params: Record, operationName: string): void { - // Store embeddings input on a separate attribute and do not truncate it + // Store embeddings input on a separate attribute if (operationName === 'embeddings' && 'input' in params) { const input = params.input; diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 5e68cafacc2c..47ddd1452693 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -93,7 +93,7 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: } const params = inputs as WorkersAiInput; - // Store embeddings input on a separate attribute and do not truncate it + // Store embeddings input on a separate attribute if (operationName === 'embeddings') { const text = params.text; diff --git a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts index 10cc5491bfa2..596c7b504c8c 100644 --- a/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/vercel-ai-request-messages.test.ts @@ -53,23 +53,6 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).not.toBe(original); }); - it('keeps all messages including inline media', () => { - const { span, recorded } = createRecordingSpan(); - - const b64 = Buffer.from('lots of data\n').toString('base64'); - const messages = [ - { role: 'user', content: 'first' }, - { role: 'user', content: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }] }, - ]; - const attributes = { [AI_PROMPT_MESSAGES_ATTRIBUTE]: JSON.stringify(messages) } as unknown as SpanAttributes; - - requestMessagesFromPrompt(span, attributes); - - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify(messages)); - expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain('first'); - expect(recorded[GEN_AI_INPUT_MESSAGES]).toContain(b64); - }); - it('does not throw and sets no attributes for malformed JSON', () => { const { span, recorded } = createRecordingSpan(); 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 6b427ecadc58..b7b974f870e3 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 @@ -96,26 +96,19 @@ describe('anthropic-ai-utils', () => { }; const span = mock as unknown as Span; - it('sets the full message value without truncation', () => { - const content = 'A'.repeat(200_000); - setMessagesAttribute(span, [{ role: 'user', content }]); + it('sets the input messages attribute', () => { + setMessagesAttribute(span, [{ role: 'user', content: 'hello, world' }]); expect(mock.attributes).toStrictEqual({ - 'gen_ai.input.messages': JSON.stringify([{ role: 'user', content }]), - }); - }); - - it('serializes non-array input as-is', () => { - setMessagesAttribute(span, { content: 'hello, world' }); - expect(mock.attributes).toStrictEqual({ - 'gen_ai.input.messages': '{"content":"hello, world"}', + '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({ - 'gen_ai.input.messages': '{"content":"hello, world"}', - }); + expect(mock.attributes).toStrictEqual(before); }); }); }); From 42cd8cf5f68332d4c570d9c44806fb1a65b3e639 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 12:45:22 +0200 Subject: [PATCH 7/8] test(server-utils): Ignore event envelopes in gen_ai span-streaming tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The span-streaming tests reuse the basic scenarios, which also emit event envelopes (error-model exceptions, captureMessage). The tests only assert on a span envelope and don't ignore events, so under the ordered envelope matcher an event arriving before the span would fail the test — it passed only because the streamed span happened to match first. Add `.ignore('event')` so the outcome no longer depends on envelope timing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../node-integration-tests/suites/tracing/anthropic/test.ts | 1 + .../node-integration-tests/suites/tracing/google-genai/test.ts | 1 + .../node-integration-tests/suites/tracing/langchain/test.ts | 1 + .../node-integration-tests/suites/tracing/langgraph/test.ts | 1 + .../node-integration-tests/suites/tracing/openai/test.ts | 1 + 5 files changed, 5 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index ac73d1f3bec3..1c16ad136210 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -624,6 +624,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates anthropic related spans with span streaming enabled', async () => { await createRunner() + .ignore('event') .expect({ span: container => { const completionSpan = container.items.find( diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index b0303890ddda..de405368d40c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -458,6 +458,7 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates google genai related spans with span streaming enabled', async () => { await createRunner() + .ignore('event') .expect({ span: container => { const generateContentSpan = container.items.find(span => span.name === 'generate_content gemini-1.5-flash'); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 4ce3bca0b2d3..e1ead1a89fe2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -403,6 +403,7 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates langchain related spans with span streaming enabled', async () => { await createRunner() + .ignore('event') .expect({ span: container => { const sonnetSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022'); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 0af7335c636f..7b04ee85f65d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -238,6 +238,7 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates langgraph related spans with span streaming enabled', async () => { await createRunner() + .ignore('event') .expect({ span: container => { const weatherTodaySpan = container.items.find(span => diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 717f2e726667..13c53a7d164f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1368,6 +1368,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { test('creates openai related spans with span streaming enabled', async () => { await createRunner() + .ignore('event') .expect({ span: container => { const chatCompletionSpan = container.items.find( From 7c4087a46c96a77208317f6ae5043d5f79d09e7f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 6 Aug 2026 13:07:31 +0200 Subject: [PATCH 8/8] test(server-utils): Drop stale enableTruncation arg from workers-ai tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addRequestAttributes` no longer takes the `enableTruncation` parameter, but six call sites in the workers-ai utils test still passed a trailing `false`. JS ignores the extra arg so the tests passed, but they were stale — remove it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/ai/lib/utils/workers-ai-utils.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts index a104600bea54..70878dc1a96e 100644 --- a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts @@ -135,7 +135,6 @@ describe('workers-ai utils', () => { ], }, 'chat', - false, ); expect(attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe( @@ -147,7 +146,7 @@ describe('workers-ai utils', () => { it('records the prompt string directly', () => { const { span, attributes } = createMockSpan(); - addRequestAttributes(span, { prompt: 'Hello world' }, 'chat', false); + addRequestAttributes(span, { prompt: 'Hello world' }, 'chat'); expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe('Hello world'); }); @@ -155,7 +154,7 @@ describe('workers-ai utils', () => { it('records embeddings input on a dedicated attribute', () => { const { span, attributes } = createMockSpan(); - addRequestAttributes(span, { text: ['embed a', 'embed b'] }, 'embeddings', false); + addRequestAttributes(span, { text: ['embed a', 'embed b'] }, 'embeddings'); expect(attributes).toEqual({ [GEN_AI_EMBEDDINGS_INPUT]: JSON.stringify(['embed a', 'embed b']) }); }); @@ -163,7 +162,7 @@ describe('workers-ai utils', () => { it('records nothing for an empty messages array', () => { const { span, attributes } = createMockSpan(); - addRequestAttributes(span, { messages: [] }, 'chat', false); + addRequestAttributes(span, { messages: [] }, 'chat'); expect(attributes).toEqual({}); }); @@ -171,7 +170,7 @@ describe('workers-ai utils', () => { it('records nothing for empty embeddings input', () => { const { span, attributes } = createMockSpan(); - addRequestAttributes(span, { text: '' }, 'embeddings', false); + addRequestAttributes(span, { text: '' }, 'embeddings'); expect(attributes).toEqual({}); }); @@ -179,7 +178,7 @@ describe('workers-ai utils', () => { it('records nothing when inputs are missing', () => { const { span, attributes } = createMockSpan(); - addRequestAttributes(span, undefined, 'chat', false); + addRequestAttributes(span, undefined, 'chat'); expect(attributes).toEqual({}); });