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 new file mode 100644 index 000000000000..2adcdd009640 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-truncation.mjs @@ -0,0 +1,19 @@ +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, + integrations: [Sentry.anthropicAIIntegration({ enableTruncation: true })], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, + streamGenAiSpans: true, +}); 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-truncation.mjs index 7df934404ff9..ce5253cc34d7 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-truncation.mjs @@ -46,7 +46,7 @@ async function run() { apiKey: 'mock-api-key', }); - const client = instrumentAnthropicAiClient(mockClient); + const client = instrumentAnthropicAiClient(mockClient, { enableTruncation: true, recordInputs: true }); // Send the image showing the number 3 // Put the image in the last message so it doesn't get dropped 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 index 49cee7e3067d..15711d019e2a 100644 --- 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 @@ -46,7 +46,7 @@ async function run() { apiKey: 'mock-api-key', }); - const client = instrumentAnthropicAiClient(mockClient); + const client = instrumentAnthropicAiClient(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 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 e29ce5514494..96760bb10e9f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -606,7 +606,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests( __dirname, 'scenario-message-truncation.mjs', - 'instrument-with-pii.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() @@ -659,51 +659,56 @@ describe('Anthropic integration', () => { }, ); - createEsmAndCjsTests(__dirname, 'scenario-media-truncation.mjs', 'instrument-with-pii.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-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]', + }, }, - }, - ], - }, - ]); - 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_ATTRIBUTE].value).toBe(expectedMediaMessages); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE].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_ATTRIBUTE].value).toBe('anthropic'); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE].value).toBe('claude-3-haiku-20240307'); - expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE].value).toBe(2); - }, - }) - .start() - .completed(); - }); - }); + // [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_ATTRIBUTE].value).toBe(expectedMediaMessages); + expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE].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_ATTRIBUTE].value).toBe('anthropic'); + expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE].value).toBe('claude-3-haiku-20240307'); + expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE].value).toBe(2); + }, + }) + .start() + .completed(); + }); + }, + ); createEsmAndCjsTests( __dirname, 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 new file mode 100644 index 000000000000..895e7d2ffbad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-with-truncation.mjs @@ -0,0 +1,19 @@ +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, + integrations: [Sentry.googleGenAIIntegration({ enableTruncation: true })], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1beta/')) { + return null; + } + return event; + }, + streamGenAiSpans: true, +}); 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 index 595728e06531..e6146214d7fe 100644 --- 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 @@ -41,7 +41,7 @@ async function run() { apiKey: 'mock-api-key', }); - const client = instrumentGoogleGenAIClient(mockClient); + const client = instrumentGoogleGenAIClient(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 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 2aad9237cd22..a172fdaa27a5 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 @@ -347,7 +347,7 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests( __dirname, 'scenario-message-truncation.mjs', - 'instrument-with-pii.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() 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 new file mode 100644 index 000000000000..f402bce82071 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-truncation.mjs @@ -0,0 +1,19 @@ +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, + 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; + }, + streamGenAiSpans: true, +}); 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 3e3ba9490c86..729be314e5fb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -199,7 +199,7 @@ describe('LangChain integration', () => { createEsmAndCjsTests( __dirname, 'scenario-message-truncation.mjs', - 'instrument-with-pii.mjs', + 'instrument-with-truncation.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit', async () => { await createRunner() 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 new file mode 100644 index 000000000000..fd4ea4c13751 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/instrument-with-truncation.mjs @@ -0,0 +1,19 @@ +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, + 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; + }, + streamGenAiSpans: true, +}); 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 15bd37a3cccb..26f0f14cecb1 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 @@ -216,7 +216,7 @@ conditionalTest({ min: 20 })('LangChain integration (v1)', () => { createEsmAndCjsTests( __dirname, 'scenario-message-truncation.mjs', - 'instrument-with-pii.mjs', + 'instrument-with-truncation.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit', async () => { await createRunner() 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 new file mode 100644 index 000000000000..abb1e1efdc28 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-with-truncation.mjs @@ -0,0 +1,18 @@ +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, + integrations: [Sentry.openAIIntegration({ enableTruncation: true })], + beforeSendTransaction: event => { + if (event.transaction.includes('/openai/')) { + return null; + } + return event; + }, + streamGenAiSpans: true, +}); 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 6f716faefe5d..ad9811a37c5f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -1174,7 +1174,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, 'truncation/scenario-message-truncation-completions.mjs', - 'instrument-with-pii.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() @@ -1278,7 +1278,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, 'truncation/scenario-message-truncation-responses.mjs', - 'instrument-with-pii.mjs', + 'instrument-with-truncation.mjs', (createRunner, test) => { test('truncates string inputs when they exceed byte limit', async () => { await createRunner() @@ -1603,7 +1603,7 @@ describe('OpenAI integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { test('redacts inline base64 image data in vision requests', async () => { await createRunner() .ignore('event') 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 index 7b0cdd730aa3..f443ab3a47fe 100644 --- 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 @@ -45,7 +45,7 @@ async function run() { apiKey: 'mock-api-key', }); - const client = instrumentOpenAiClient(mockClient); + const client = 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 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 index aebd3341eb33..692f5fd87ca7 100644 --- 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 @@ -77,7 +77,7 @@ async function run() { apiKey: 'mock-api-key', }); - const client = instrumentOpenAiClient(mockClient); + const client = 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 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 new file mode 100644 index 000000000000..7afab89bece4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-truncation.mjs @@ -0,0 +1,12 @@ +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, + integrations: [Sentry.vercelAIIntegration({ enableTruncation: true })], + streamGenAiSpans: true, +}); 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 1372a54cdeb1..350207b16af0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -446,7 +446,7 @@ describe('Vercel AI integration', () => { createEsmAndCjsTests( __dirname, 'scenario-message-truncation.mjs', - 'instrument-with-pii.mjs', + 'instrument-with-truncation.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit', async () => { await createRunner() diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 2c152b3fd8f2..82ac952e4b9a 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -60,11 +60,22 @@ export function resolveAIRecordingOptions(options? /** * Resolves whether truncation should be enabled. * If the user explicitly set `enableTruncation`, that value is used. - * Otherwise, truncation is disabled when span streaming is active. + * Otherwise, truncation is disabled whenever gen_ai spans are sent through the span streaming / v2 + * span path, i.e. full span streaming (`traceLifecycle: 'stream'`) or `streamGenAiSpans`. 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; + } + const client = getClient(); - return enableTruncation ?? !(client && hasSpanStreamingEnabled(client)); + if (!client) { + return true; + } + + return !hasSpanStreamingEnabled(client) && !client.getOptions().streamGenAiSpans; } /** diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index a6029ee86e2f..f40a79c04ecf 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -569,6 +569,11 @@ export interface ClientOptions { @@ -83,6 +87,55 @@ describe('resolveAIRecordingOptions', () => { }); }); +describe('shouldEnableTruncation', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + 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 true with a default client (no streaming)', () => { + setupClient(); + expect(shouldEnableTruncation(undefined)).toBe(true); + }); + + it('defaults to false when streamGenAiSpans is enabled', () => { + setupClient({ streamGenAiSpans: true }); + 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 streamGenAiSpans', () => { + setupClient({ streamGenAiSpans: true }); + 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.