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/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 dc728cbe806f..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, @@ -78,7 +79,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 +125,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) : getJsonString(filteredMessages), + ); if (Array.isArray(filteredMessages)) { span.setAttribute(GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, filteredMessages.length); @@ -162,7 +170,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 +208,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 {