From 03ad5505e51ecd945df398f0812a8e8f443cf3df Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Apr 2026 21:09:26 +0900 Subject: [PATCH 1/3] feat(core): Add `enableTruncation` option to OpenAI integration This PR adds an `enableTruncation` option to the OpenAI integration that allows users to disable inpute message truncation. It defaults to `true` to preserve existing behavior. Closes: #20135 --- packages/core/src/tracing/openai/index.ts | 17 ++-- packages/core/src/tracing/openai/types.ts | 5 ++ .../tracing/openai-enable-truncation.test.ts | 86 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/tracing/openai-enable-truncation.test.ts diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index dc728cbe806f..7681aa004088 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -78,7 +78,12 @@ function extractRequestAttributes(args: unknown[], operationName: string): Recor } // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. -function addRequestAttributes(span: Span, params: Record, operationName: string): void { +function addRequestAttributes( + span: Span, + params: Record, + operationName: string, + enableTruncation: boolean, +): void { // Store embeddings input on a separate attribute and do not truncate it if (operationName === 'embeddings' && 'input' in params) { const input = params.input; @@ -119,8 +124,10 @@ function addRequestAttributes(span: Span, params: Record, opera span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, systemInstructions); } - const truncatedInput = getTruncatedJsonString(filteredMessages); - span.setAttribute(GEN_AI_INPUT_MESSAGES_ATTRIBUTE, truncatedInput); + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + enableTruncation ? getTruncatedJsonString(filteredMessages) : JSON.stringify(filteredMessages), + ); if (Array.isArray(filteredMessages)) { span.setAttribute(GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, filteredMessages.length); @@ -162,7 +169,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName); + addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); } // Return async processing @@ -200,7 +207,7 @@ function instrumentMethod( originalResult = originalMethod.apply(context, args); if (options.recordInputs && params) { - addRequestAttributes(span, params, operationName); + addRequestAttributes(span, params, operationName, options.enableTruncation ?? true); } return originalResult.then( diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index dd6872bb691b..794c7ca49f8a 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -22,6 +22,11 @@ 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/core/test/tracing/openai-enable-truncation.test.ts b/packages/core/test/tracing/openai-enable-truncation.test.ts new file mode 100644 index 000000000000..d5569203a7e1 --- /dev/null +++ b/packages/core/test/tracing/openai-enable-truncation.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { OpenAiClient } from '../../src'; +import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, spanToJSON, startSpan } from '../../src'; +import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE } from '../../src/tracing/ai/gen-ai-attributes'; +import { instrumentOpenAiClient } from '../../src/tracing/openai'; +import type { Span } from '../../src/types-hoist/span'; +import { getSpanDescendants } from '../../src/utils/spanUtils'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +function createMockOpenAiClient() { + return { + chat: { + completions: { + create: async (params: { model: string; messages: Array<{ role: string; content: string }> }) => ({ + id: 'chatcmpl-test', + model: params.model, + choices: [{ message: { content: 'Hello!' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + }, + }, + }; +} + +type MockClient = ReturnType; + +describe('OpenAI enableTruncation option', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + async function callWithOptions( + options: { recordInputs?: boolean; enableTruncation?: boolean }, + messages: Array<{ role: string; content: string }>, + ): Promise { + const mockClient = createMockOpenAiClient(); + const instrumented = instrumentOpenAiClient(mockClient as unknown as OpenAiClient, options) as MockClient; + + let rootSpan: Span | undefined; + + await startSpan({ name: 'test' }, async span => { + rootSpan = span; + await instrumented.chat.completions.create({ model: 'gpt-4', messages }); + }); + + const spans = getSpanDescendants(rootSpan!); + const aiSpan = spans.find(s => spanToJSON(s).op === 'gen_ai.chat'); + return spanToJSON(aiSpan!).data?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE] as string | undefined; + } + + const longContent = 'A'.repeat(200_000); + const messages = [{ role: 'user', content: longContent }]; + + it('truncates input messages by default', async () => { + const inputMessages = await callWithOptions({ recordInputs: true }, messages); + expect(inputMessages).toBeDefined(); + expect(inputMessages!.length).toBeLessThan(longContent.length); + }); + + it('truncates input messages when enableTruncation is true', async () => { + const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: true }, messages); + expect(inputMessages).toBeDefined(); + expect(inputMessages!.length).toBeLessThan(longContent.length); + }); + + it('does not truncate input messages when enableTruncation is false', async () => { + const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: false }, messages); + expect(inputMessages).toBeDefined(); + + const parsed = JSON.parse(inputMessages!); + expect(parsed).toEqual(messages); + }); +}); From 995f0b4ea2a8eba949f80f24395ce899491d670e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Apr 2026 21:37:11 +0900 Subject: [PATCH 2/3] Ensure pure strings are not stringified --- packages/core/src/tracing/ai/utils.ts | 11 +++++++++ packages/core/src/tracing/openai/index.ts | 3 ++- .../tracing/openai-enable-truncation.test.ts | 24 +++++++++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 601807cc194d..d3cce644dbc1 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -169,6 +169,17 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp span.end(); } +/** + * Serialize a value to a JSON string without truncation. + * Strings are returned as-is, arrays and objects are JSON-stringified. + */ +export function getJsonString(value: T | T[]): string { + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value); +} + /** * Get the truncated JSON string for a string or array of strings. * diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 7681aa004088..f1c4d3a06516 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -19,6 +19,7 @@ import type { InstrumentedMethodEntry } from '../ai/utils'; import { buildMethodPath, extractSystemInstructions, + getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, @@ -126,7 +127,7 @@ function addRequestAttributes( span.setAttribute( GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(filteredMessages) : JSON.stringify(filteredMessages), + enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), ); if (Array.isArray(filteredMessages)) { diff --git a/packages/core/test/tracing/openai-enable-truncation.test.ts b/packages/core/test/tracing/openai-enable-truncation.test.ts index d5569203a7e1..394a7b609888 100644 --- a/packages/core/test/tracing/openai-enable-truncation.test.ts +++ b/packages/core/test/tracing/openai-enable-truncation.test.ts @@ -19,6 +19,16 @@ function createMockOpenAiClient() { }), }, }, + responses: { + create: async (params: { model: string; input: string }) => ({ + id: 'resp-test', + object: 'response', + model: params.model, + output_text: 'Response text', + status: 'completed', + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }), + }, }; } @@ -44,7 +54,7 @@ describe('OpenAI enableTruncation option', () => { async function callWithOptions( options: { recordInputs?: boolean; enableTruncation?: boolean }, - messages: Array<{ role: string; content: string }>, + input: Array<{ role: string; content: string }> | string, ): Promise { const mockClient = createMockOpenAiClient(); const instrumented = instrumentOpenAiClient(mockClient as unknown as OpenAiClient, options) as MockClient; @@ -53,7 +63,11 @@ describe('OpenAI enableTruncation option', () => { await startSpan({ name: 'test' }, async span => { rootSpan = span; - await instrumented.chat.completions.create({ model: 'gpt-4', messages }); + if (typeof input === 'string') { + await instrumented.responses.create({ model: 'gpt-4', input }); + } else { + await instrumented.chat.completions.create({ model: 'gpt-4', messages: input }); + } }); const spans = getSpanDescendants(rootSpan!); @@ -83,4 +97,10 @@ describe('OpenAI enableTruncation option', () => { const parsed = JSON.parse(inputMessages!); expect(parsed).toEqual(messages); }); + + it('does not wrap string input in quotes when enableTruncation is false', async () => { + const stringInput = 'Translate this to French: Hello'; + const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: false }, stringInput); + expect(inputMessages).toBe(stringInput); + }); }); From a59961b718b81c7db50b5e94c4196483b3d4658e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Apr 2026 22:02:40 +0900 Subject: [PATCH 3/3] Move the truncation tests to the integration test suite --- .../openai/instrument-no-truncation.mjs | 23 ++++ .../tracing/openai/scenario-no-truncation.mjs | 81 +++++++++++++ .../suites/tracing/openai/test.ts | 37 ++++++ .../tracing/openai-enable-truncation.test.ts | 106 ------------------ 4 files changed, 141 insertions(+), 106 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs delete mode 100644 packages/core/test/tracing/openai-enable-truncation.test.ts 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 new file mode 100644 index 000000000000..0dd039762f1f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs @@ -0,0 +1,23 @@ +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, + sendDefaultPii: 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/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..f19345653c07 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs @@ -0,0 +1,81 @@ +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', + }); + + // Chat completion with long content (would normally be truncated) + 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 (would normally be truncated) + const longStringInput = 'B'.repeat(50_000); + await client.responses.create({ + model: 'gpt-4', + input: longStringInput, + }); + }); + + 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 ae7715e9852c..d3bdc0a6a80c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -345,6 +345,43 @@ describe('OpenAI integration', () => { }); }); + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + // Chat completion with long content should not be truncated + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([{ role: 'user', content: longContent }]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + // Responses API long string input should not be truncated or wrapped in quotes + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: 'B'.repeat(50_000), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + ]), + }; + + 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: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { transaction: 'main', spans: expect.arrayContaining([ diff --git a/packages/core/test/tracing/openai-enable-truncation.test.ts b/packages/core/test/tracing/openai-enable-truncation.test.ts deleted file mode 100644 index 394a7b609888..000000000000 --- a/packages/core/test/tracing/openai-enable-truncation.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { OpenAiClient } from '../../src'; -import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, spanToJSON, startSpan } from '../../src'; -import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE } from '../../src/tracing/ai/gen-ai-attributes'; -import { instrumentOpenAiClient } from '../../src/tracing/openai'; -import type { Span } from '../../src/types-hoist/span'; -import { getSpanDescendants } from '../../src/utils/spanUtils'; -import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; - -function createMockOpenAiClient() { - return { - chat: { - completions: { - create: async (params: { model: string; messages: Array<{ role: string; content: string }> }) => ({ - id: 'chatcmpl-test', - model: params.model, - choices: [{ message: { content: 'Hello!' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }), - }, - }, - responses: { - create: async (params: { model: string; input: string }) => ({ - id: 'resp-test', - object: 'response', - model: params.model, - output_text: 'Response text', - status: 'completed', - usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, - }), - }, - }; -} - -type MockClient = ReturnType; - -describe('OpenAI enableTruncation option', () => { - beforeEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - getGlobalScope().clear(); - - const options = getDefaultTestClientOptions({ tracesSampleRate: 1 }); - const client = new TestClient(options); - setCurrentClient(client); - client.init(); - }); - - afterEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - getGlobalScope().clear(); - }); - - async function callWithOptions( - options: { recordInputs?: boolean; enableTruncation?: boolean }, - input: Array<{ role: string; content: string }> | string, - ): Promise { - const mockClient = createMockOpenAiClient(); - const instrumented = instrumentOpenAiClient(mockClient as unknown as OpenAiClient, options) as MockClient; - - let rootSpan: Span | undefined; - - await startSpan({ name: 'test' }, async span => { - rootSpan = span; - if (typeof input === 'string') { - await instrumented.responses.create({ model: 'gpt-4', input }); - } else { - await instrumented.chat.completions.create({ model: 'gpt-4', messages: input }); - } - }); - - const spans = getSpanDescendants(rootSpan!); - const aiSpan = spans.find(s => spanToJSON(s).op === 'gen_ai.chat'); - return spanToJSON(aiSpan!).data?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE] as string | undefined; - } - - const longContent = 'A'.repeat(200_000); - const messages = [{ role: 'user', content: longContent }]; - - it('truncates input messages by default', async () => { - const inputMessages = await callWithOptions({ recordInputs: true }, messages); - expect(inputMessages).toBeDefined(); - expect(inputMessages!.length).toBeLessThan(longContent.length); - }); - - it('truncates input messages when enableTruncation is true', async () => { - const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: true }, messages); - expect(inputMessages).toBeDefined(); - expect(inputMessages!.length).toBeLessThan(longContent.length); - }); - - it('does not truncate input messages when enableTruncation is false', async () => { - const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: false }, messages); - expect(inputMessages).toBeDefined(); - - const parsed = JSON.parse(inputMessages!); - expect(parsed).toEqual(messages); - }); - - it('does not wrap string input in quotes when enableTruncation is false', async () => { - const stringInput = 'Translate this to French: Hello'; - const inputMessages = await callWithOptions({ recordInputs: true, enableTruncation: false }, stringInput); - expect(inputMessages).toBe(stringInput); - }); -});