From e647ba16c18c6da0516ac648a812fe6c42820f00 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 3 Sep 2025 16:49:31 +0200 Subject: [PATCH 1/6] fix(node): Add `origin` for OpenAI spans & test auto instrumentation (#17519) We used to mock the OpenAI client and manually instrument this. This adds a test for actual auto-instrumentation of OpenAI, and adjusts the origin to be fixed. --- .../suites/tracing/openai/test.ts | 2 +- .../node-integration-tests/package.json | 1 + .../tracing/openai/openai-tool-calls/test.ts | 32 +++---- .../tracing/openai/scenario-root-span.mjs | 61 ++++++++++++ .../suites/tracing/openai/test.ts | 95 ++++++++++++++----- packages/core/src/utils/openai/index.ts | 2 + yarn.lock | 5 + 7 files changed, 157 insertions(+), 41 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 1dc4ca077665..fc38fc6339b2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -31,7 +31,7 @@ it('traces a basic chat completion request', async () => { }), description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', }), ]), ); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e779ec11671f..0ee77860f21d 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -58,6 +58,7 @@ "nock": "^13.5.5", "node-cron": "^3.0.3", "node-schedule": "^2.1.1", + "openai": "5.18.1", "pg": "8.16.0", "postgres": "^3.4.7", "proxy": "^2.1.1", diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index c5fd4fc97a72..a03181d5625b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -65,7 +65,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, @@ -83,7 +83,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Second span - chat completion with tools and streaming @@ -91,7 +91,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -111,7 +111,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Third span - responses API with tools (non-streaming) @@ -119,7 +119,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, @@ -137,7 +137,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Fourth span - responses API with tools and streaming @@ -145,7 +145,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -165,7 +165,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), ]), @@ -179,7 +179,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', @@ -200,7 +200,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Second span - chat completion with tools and streaming with PII @@ -208,7 +208,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -230,7 +230,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Third span - responses API with tools (non-streaming) with PII @@ -238,7 +238,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', @@ -258,7 +258,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Fourth span - responses API with tools and streaming with PII @@ -266,7 +266,7 @@ describe('OpenAI Tool Calls integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -288,7 +288,7 @@ describe('OpenAI Tool Calls integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), ]), diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs new file mode 100644 index 000000000000..d1a06e5ccbb2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs @@ -0,0 +1,61 @@ +import express from 'express'; +import OpenAI from 'openai'; + +const PORT = 3333; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + }); + return app.listen(PORT); +} + +async function run() { + const server = startMockOpenAiServer(); + + const client = new OpenAI({ + baseURL: `http://localhost:${PORT}/openai`, + apiKey: 'mock-api-key', + }); + + const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + 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 e72d0144d36c..967cf55bb130 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -14,7 +14,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -32,7 +32,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Second span - responses API @@ -40,7 +40,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -57,7 +57,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-3.5-turbo', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Third span - error handling @@ -65,13 +65,13 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', }, description: 'chat error-model', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'unknown_error', }), // Fourth span - chat completions streaming @@ -79,7 +79,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, @@ -99,7 +99,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Fifth span - responses API streaming @@ -107,7 +107,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -126,7 +126,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Sixth span - error handling in streaming context @@ -137,11 +137,11 @@ describe('OpenAI integration', () => { 'gen_ai.request.stream': true, 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', }, description: 'chat error-model stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'internal_error', }), ]), @@ -155,7 +155,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, @@ -176,7 +176,7 @@ describe('OpenAI integration', () => { }, description: 'chat gpt-3.5-turbo', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Second span - responses API with PII @@ -184,7 +184,7 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.messages': '"Translate this to French: Hello"', @@ -203,7 +203,7 @@ describe('OpenAI integration', () => { }, description: 'responses gpt-3.5-turbo', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Third span - error handling with PII @@ -211,14 +211,14 @@ describe('OpenAI integration', () => { data: { 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'unknown_error', }), // Fourth span - chat completions streaming with PII @@ -226,7 +226,7 @@ describe('OpenAI integration', () => { data: expect.objectContaining({ 'gen_ai.operation.name': 'chat', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, @@ -249,7 +249,7 @@ describe('OpenAI integration', () => { }), description: 'chat gpt-4 stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Fifth span - responses API streaming with PII @@ -257,7 +257,7 @@ describe('OpenAI integration', () => { data: expect.objectContaining({ 'gen_ai.operation.name': 'responses', 'sentry.op': 'gen_ai.responses', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, @@ -278,7 +278,7 @@ describe('OpenAI integration', () => { }), description: 'responses gpt-4 stream-response', op: 'gen_ai.responses', - origin: 'manual', + origin: 'auto.function.openai', status: 'ok', }), // Sixth span - error handling in streaming context with PII @@ -290,11 +290,11 @@ describe('OpenAI integration', () => { 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', - 'sentry.origin': 'manual', + 'sentry.origin': 'auto.function.openai', }, description: 'chat error-model stream-response', op: 'gen_ai.chat', - origin: 'manual', + origin: 'auto.function.openai', status: 'internal_error', }), ]), @@ -350,4 +350,51 @@ describe('OpenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument.mjs', (createRunner, test) => { + test('it works without a wrapping span', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /openai/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.function.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.function.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 3fb8b1bf8b98..060117d52964 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -1,5 +1,6 @@ import { getCurrentScope } from '../../currentScopes'; import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; @@ -49,6 +50,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'openai', [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.openai', }; // Chat completion API accepts web_search_options and tools as parameters diff --git a/yarn.lock b/yarn.lock index d4a6aa4ec1be..225ff96259e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23845,6 +23845,11 @@ open@^9.1.0: is-inside-container "^1.0.0" is-wsl "^2.2.0" +openai@5.18.1: + version "5.18.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-5.18.1.tgz#1c4884aefcada7ec684771e03c860c381f1902c1" + integrity sha512-iXSOfLlOL+jgnFr5CGrB2SEZw5C92o1nrFW2SasoAXj4QxGhfeJPgg8zkX+vaCfX80cT6CWjgaGnq7z9XzbyRw== + opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" From 64e486dcb3084459e9de4b2eeacc691371b46df9 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 3 Sep 2025 09:01:00 -0700 Subject: [PATCH 2/6] feat(feedback): Add more labels so people can configure Highlight and Hide labels (#17513) Fixes https://github.com/getsentry/sentry-javascript/issues/17493 --- packages/core/src/types-hoist/feedback/config.ts | 15 +++++++++++++++ packages/feedback/src/constants/index.ts | 3 +++ packages/feedback/src/core/integration.ts | 11 +++++++++++ .../screenshot/components/ScreenshotEditor.tsx | 4 ++-- .../src/screenshot/components/Toolbar.tsx | 7 +++++-- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index 49bc7231e9e2..f6a90c7c5b73 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -176,6 +176,21 @@ export interface FeedbackTextConfiguration { * The label for the button that removes a screenshot and hides the image editor */ removeScreenshotButtonLabel: string; + + /** + * The label for the button that highlights portions ofthe screenshot + */ + highlightToolText: string; + + /** + * The label for the button that hides portions of the screenshot + */ + hideToolText: string; + + /** + * The label for the button that removed a highlight/hidden section of the screenshot. + */ + removeHighlightText: string; } /** diff --git a/packages/feedback/src/constants/index.ts b/packages/feedback/src/constants/index.ts index 198b6e199bb5..d18392258417 100644 --- a/packages/feedback/src/constants/index.ts +++ b/packages/feedback/src/constants/index.ts @@ -22,6 +22,9 @@ export const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; export const IS_REQUIRED_LABEL = '(required)'; export const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; export const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; +export const HIGHLIGHT_TOOL_TEXT = 'Highlight'; +export const HIDE_TOOL_TEXT = 'Hide'; +export const REMOVE_HIGHLIGHT_TEXT = 'Remove'; export const FEEDBACK_WIDGET_SOURCE = 'widget'; export const FEEDBACK_API_SOURCE = 'api'; diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index d70f563b6136..87ab62cfec1b 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines */ + import type { FeedbackInternalOptions, FeedbackModalIntegration, @@ -14,11 +16,14 @@ import { EMAIL_LABEL, EMAIL_PLACEHOLDER, FORM_TITLE, + HIDE_TOOL_TEXT, + HIGHLIGHT_TOOL_TEXT, IS_REQUIRED_LABEL, MESSAGE_LABEL, MESSAGE_PLACEHOLDER, NAME_LABEL, NAME_PLACEHOLDER, + REMOVE_HIGHLIGHT_TEXT, REMOVE_SCREENSHOT_LABEL, SUBMIT_BUTTON_LABEL, SUCCESS_MESSAGE_TEXT, @@ -110,6 +115,9 @@ export const buildFeedbackIntegration = ({ successMessageText = SUCCESS_MESSAGE_TEXT, triggerLabel = TRIGGER_LABEL, triggerAriaLabel = '', + highlightToolText = HIGHLIGHT_TOOL_TEXT, + hideToolText = HIDE_TOOL_TEXT, + removeHighlightText = REMOVE_HIGHLIGHT_TEXT, // FeedbackCallbacks onFormOpen, @@ -152,6 +160,9 @@ export const buildFeedbackIntegration = ({ isRequiredLabel, addScreenshotButtonLabel, removeScreenshotButtonLabel, + highlightToolText, + hideToolText, + removeHighlightText, onFormClose, onFormOpen, diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 083f5c16dec6..8995566fa8c2 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -310,7 +310,7 @@ export function ScreenshotEditorFactory({ }} > From 3c048c385e05fdef99fca93ca073e2ccf647ce77 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 4 Sep 2025 13:22:45 +0200 Subject: [PATCH 3/6] feat(browser): Add support for `propagateTraceparent` SDK option (#17509) This PR adds support for a new browser SDK init option, `propagateTraceparent`, as [spec'd out in our develop docs](https://develop.sentry.dev/sdk/telemetry/traces/#propagatetraceparent) If users opt into `propagateTraceparent`, browser SDKs will attach a W3C compliant `traceparent` header to outgoing fetch and XHR requests, in addition to `sentry-trace` and `baggage` headers. --- .size-limit.js | 6 +- .../defaultTargetsNoMatch/init.js | 1 + .../defaultTargetsNoMatch/test.ts | 3 +- .../fetch-propagateTraceparent/init.js | 11 +++ .../fetch-propagateTraceparent/subject.js | 5 ++ .../fetch-propagateTraceparent/test.ts | 60 ++++++++++++++++ .../init.js | 11 +++ .../subject.js | 1 + .../test.ts | 32 +++++++++ .../init.js | 11 +++ .../subject.js | 1 + .../test.ts | 32 +++++++++ .../init.js | 11 +++ .../subject.js | 3 + .../test.ts | 26 +++++++ .../suites/tracing/request/fetch/test.ts | 6 +- .../request/xhr-propagateTraceparent/init.js | 11 +++ .../xhr-propagateTraceparent/subject.js | 12 ++++ .../request/xhr-propagateTraceparent/test.ts | 58 ++++++++++++++++ .../init.js | 11 +++ .../subject.js | 3 + .../test.ts | 25 +++++++ .../init.js | 10 +++ .../subject.js | 3 + .../test.ts | 32 +++++++++ .../init.js | 11 +++ .../subject.js | 9 +++ .../test.ts | 28 ++++++++ packages/browser/src/client.ts | 29 +++++--- packages/browser/src/tracing/request.ts | 44 +++++++++--- packages/browser/test/tracing/request.test.ts | 4 ++ packages/cloudflare/src/integrations/fetch.ts | 4 +- .../test/integrations/fetch.test.ts | 2 +- packages/core/src/fetch.ts | 69 +++++++++++++++++-- packages/core/src/index.ts | 5 +- packages/core/src/integration.ts | 6 +- packages/core/src/tracing/sampling.ts | 4 +- packages/core/src/types-hoist/options.ts | 8 +-- packages/core/src/types-hoist/tracing.ts | 1 + packages/core/src/utils/hasSpansEnabled.ts | 4 +- packages/core/src/utils/sdkMetadata.ts | 4 +- packages/core/src/utils/traceData.ts | 42 +++++++++-- packages/core/src/utils/tracing.ts | 3 + .../core/test/lib/utils/traceData.test.ts | 68 ++++++++++++++++++ .../src/integrations/wintercg-fetch.ts | 10 +-- .../vercel-edge/test/wintercg-fetch.test.ts | 2 +- 46 files changed, 687 insertions(+), 55 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/test.ts diff --git a/.size-limit.js b/.size-limit.js index e8e779c9439c..3819ee2e91f6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -75,7 +75,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '83 KB', + limit: '84 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -120,7 +120,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '42 KB', + limit: '43 KB', }, // Vue SDK (ESM) { @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '44 KB', + limit: '45 KB', }, // SvelteKit SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/init.js index 83076460599f..d7b0a648a2de 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/init.js @@ -6,4 +6,5 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], tracesSampleRate: 1, + propagateTraceparent: true, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/test.ts index 9851d51b99d6..ac6931fab85a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/tracePropagationTargets/defaultTargetsNoMatch/test.ts @@ -3,7 +3,7 @@ import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; sentryTest( - 'should not attach `sentry-trace` and `baggage` header to cross-origin requests when no tracePropagationTargets are defined', + "doesn't attach `sentry-trace` and `baggage` or `traceparent` (if `propagateTraceparent` is true) header to cross-origin requests when no tracePropagationTargets are defined", async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); @@ -25,6 +25,7 @@ sentryTest( expect(requestHeaders).not.toMatchObject({ 'sentry-trace': expect.any(String), baggage: expect.any(String), + traceparent: expect.any(String), }); } }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/init.js new file mode 100644 index 000000000000..cb785bbf7fda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/subject.js new file mode 100644 index 000000000000..482a738009c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/subject.js @@ -0,0 +1,5 @@ +fetch('http://sentry-test-site.example/0').then( + fetch('http://sentry-test-site.example/1', { headers: { 'X-Test-Header': 'existing-header' } }).then( + fetch('http://sentry-test-site.example/2'), + ), +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/test.ts new file mode 100644 index 000000000000..d0aedc65bac8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-propagateTraceparent/test.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to fetch requests if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://sentry-test-site.example/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + const traceparentData1 = extractTraceparentData(requestHeaders1['sentry-trace']); + expect(traceparentData1).toMatchObject({ + traceId: expect.stringMatching(/^([a-f0-9]{32})$/), + parentSpanId: expect.stringMatching(/^([a-f0-9]{16})$/), + parentSampled: true, + }); + + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': `${traceparentData1?.traceId}-${traceparentData1?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData1?.traceId}`), + traceparent: `00-${traceparentData1?.traceId}-${traceparentData1?.parentSpanId}-01`, + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + const traceparentData2 = extractTraceparentData(requestHeaders2['sentry-trace']); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': `${traceparentData2?.traceId}-${traceparentData2?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData2?.traceId}`), + traceparent: `00-${traceparentData2?.traceId}-${traceparentData2?.parentSpanId}-01`, + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + const traceparentData3 = extractTraceparentData(requestHeaders3['sentry-trace']); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': `${traceparentData3?.traceId}-${traceparentData3?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData3?.traceId}`), + traceparent: `00-${traceparentData3?.traceId}-${traceparentData3?.parentSpanId}-01`, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/init.js new file mode 100644 index 000000000000..fa09e056425e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 0, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/subject.js new file mode 100644 index 000000000000..f5e7312a1961 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/subject.js @@ -0,0 +1 @@ +fetch('http://sentry-test-site.example/0'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/test.ts new file mode 100644 index 000000000000..26815021f0e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-unsampled-propagateTraceparent/test.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to unsampled fetch requests if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, request] = await Promise.all([page.goto(url), page.waitForRequest('http://sentry-test-site.example/0')]); + + const requestHeaders = request.headers(); + + const traceparentData = extractTraceparentData(requestHeaders['sentry-trace']); + expect(traceparentData).toMatchObject({ + traceId: expect.stringMatching(/^([a-f0-9]{32})$/), + parentSpanId: expect.stringMatching(/^([a-f0-9]{16})$/), + parentSampled: false, + }); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': `${traceparentData?.traceId}-${traceparentData?.parentSpanId}-0`, + traceparent: `00-${traceparentData?.traceId}-${traceparentData?.parentSpanId}-00`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData?.traceId}`), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/init.js new file mode 100644 index 000000000000..8d3c1948d89d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + // no tracesSampleRate defined means TWP mode + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/subject.js new file mode 100644 index 000000000000..e9a1ca98b5b0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/subject.js @@ -0,0 +1 @@ +fetch('http://sentry-test-site.example/0').then(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/test.ts new file mode 100644 index 000000000000..22cc7ba98eb8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-tracing-without-performance-propagateTraceparent/test.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to tracing without performance (TWP) fetch requests, if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, request] = await Promise.all([page.goto(url), page.waitForRequest('http://sentry-test-site.example/0')]); + + const requestHeaders = request.headers(); + + const traceparentData = extractTraceparentData(requestHeaders['sentry-trace']); + expect(traceparentData).toMatchObject({ + traceId: expect.stringMatching(/^([a-f0-9]{32})$/), + parentSpanId: expect.stringMatching(/^([a-f0-9]{16})$/), + parentSampled: undefined, + }); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': `${traceparentData?.traceId}-${traceparentData?.parentSpanId}`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData?.traceId}`), + traceparent: `00-${traceparentData?.traceId}-${traceparentData?.parentSpanId}-00`, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/init.js new file mode 100644 index 000000000000..cb785bbf7fda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/subject.js new file mode 100644 index 000000000000..8f5a109f0a6d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/subject.js @@ -0,0 +1,3 @@ +fetch('http://sentry-test-site.example/api/test/', { + headers: { 'sentry-trace': 'abc-123-1', baggage: 'sentry-trace_id=abc', traceparent: '00-abc-123-01' }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/test.ts new file mode 100644 index 000000000000..f1b163fc0778 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers-propagateTraceparent/test.ts @@ -0,0 +1,26 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + "instrumentation doesn't override manually added traceparent header, if `propagateTraceparent` is true", + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/api/test/'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const request = await requestPromise; + + const headers = await request.allHeaders(); + + expect(headers['sentry-trace']).toBe('abc-123-1'); + expect(headers.baggage).toBe('sentry-trace_id=abc'); + expect(headers.traceparent).toBe('00-abc-123-01'); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts index 9e2a2dc9bd8b..72a196d5d8e9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch/test.ts @@ -41,7 +41,7 @@ sentryTest('should create spans for fetch requests', async ({ getLocalTestUrl, p ); }); -sentryTest('should attach `sentry-trace` header to fetch requests', async ({ getLocalTestUrl, page }) => { +sentryTest('attaches `sentry-trace` and `baggage` headers to fetch requests', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } @@ -65,6 +65,8 @@ sentryTest('should attach `sentry-trace` header to fetch requests', async ({ get 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), baggage: expect.any(String), }); + // traceparent must only be attached if propagateTraceparent is `true` + expect(requestHeaders1).not.toHaveProperty('traceparent'); const request2 = requests[1]; const requestHeaders2 = request2.headers(); @@ -73,6 +75,7 @@ sentryTest('should attach `sentry-trace` header to fetch requests', async ({ get baggage: expect.any(String), 'x-test-header': 'existing-header', }); + expect(requestHeaders2).not.toHaveProperty('traceparent'); const request3 = requests[2]; const requestHeaders3 = request3.headers(); @@ -80,4 +83,5 @@ sentryTest('should attach `sentry-trace` header to fetch requests', async ({ get 'sentry-trace': expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/), baggage: expect.any(String), }); + expect(requestHeaders3).not.toHaveProperty('traceparent'); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/init.js new file mode 100644 index 000000000000..cb785bbf7fda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/subject.js new file mode 100644 index 000000000000..9c584bf743cb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/subject.js @@ -0,0 +1,12 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); + +const xhr_2 = new XMLHttpRequest(); +xhr_2.open('GET', 'http://sentry-test-site.example/1'); +xhr_2.setRequestHeader('X-Test-Header', 'existing-header'); +xhr_2.send(); + +const xhr_3 = new XMLHttpRequest(); +xhr_3.open('GET', 'http://sentry-test-site.example/2'); +xhr_3.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/test.ts new file mode 100644 index 000000000000..1289dfb6cdcc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-propagateTraceparent/test.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to XHR requests if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requests = ( + await Promise.all([ + page.goto(url), + Promise.all([0, 1, 2].map(idx => page.waitForRequest(`http://sentry-test-site.example/${idx}`))), + ]) + )[1]; + + expect(requests).toHaveLength(3); + + const request1 = requests[0]; + const requestHeaders1 = request1.headers(); + const traceparentData1 = extractTraceparentData(requestHeaders1['sentry-trace']); + expect(traceparentData1).toMatchObject({ + traceId: expect.stringMatching(/^([a-f0-9]{32})$/), + parentSpanId: expect.stringMatching(/^([a-f0-9]{16})$/), + parentSampled: true, + }); + + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': `${traceparentData1?.traceId}-${traceparentData1?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData1?.traceId}`), + traceparent: `00-${traceparentData1?.traceId}-${traceparentData1?.parentSpanId}-01`, + }); + + const request2 = requests[1]; + const requestHeaders2 = request2.headers(); + const traceparentData2 = extractTraceparentData(requestHeaders2['sentry-trace']); + expect(requestHeaders2).toMatchObject({ + 'sentry-trace': `${traceparentData2?.traceId}-${traceparentData2?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData2?.traceId}`), + traceparent: `00-${traceparentData2?.traceId}-${traceparentData2?.parentSpanId}-01`, + 'x-test-header': 'existing-header', + }); + + const request3 = requests[2]; + const requestHeaders3 = request3.headers(); + const traceparentData3 = extractTraceparentData(requestHeaders3['sentry-trace']); + expect(requestHeaders3).toMatchObject({ + 'sentry-trace': `${traceparentData3?.traceId}-${traceparentData3?.parentSpanId}-1`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData3?.traceId}`), + traceparent: `00-${traceparentData3?.traceId}-${traceparentData3?.parentSpanId}-01`, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/init.js new file mode 100644 index 000000000000..fa09e056425e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 0, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/subject.js new file mode 100644 index 000000000000..1c0ec68582e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/subject.js @@ -0,0 +1,3 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/test.ts new file mode 100644 index 000000000000..abcb25776c83 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-unsampled-propagateTraceparent/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to unsampled xhr requests, if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, request] = await Promise.all([page.goto(url), page.waitForRequest('http://sentry-test-site.example/0')]); + + const requestHeaders1 = request.headers(); + const traceparentData = extractTraceparentData(requestHeaders1['sentry-trace']); + expect(requestHeaders1).toMatchObject({ + 'sentry-trace': `${traceparentData?.traceId}-${traceparentData?.parentSpanId}-0`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData?.traceId}`), + traceparent: `00-${traceparentData?.traceId}-${traceparentData?.parentSpanId}-00`, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/init.js new file mode 100644 index 000000000000..db749bf038e0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/subject.js new file mode 100644 index 000000000000..1c0ec68582e3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/subject.js @@ -0,0 +1,3 @@ +const xhr_1 = new XMLHttpRequest(); +xhr_1.open('GET', 'http://sentry-test-site.example/0'); +xhr_1.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/test.ts new file mode 100644 index 000000000000..de1afa51f962 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-tracing-without-performance-propagateTraceparent/test.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { extractTraceparentData } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches traceparent header to tracing without performance (TWP) xhr requests, if `propagateTraceparent` is true', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [, request] = await Promise.all([page.goto(url), page.waitForRequest('http://sentry-test-site.example/0')]); + + const requestHeaders = request.headers(); + const traceparentData = extractTraceparentData(requestHeaders['sentry-trace']); + + expect(traceparentData).toEqual({ + traceId: expect.stringMatching(/^([a-f0-9]{32})$/), + parentSpanId: expect.stringMatching(/^([a-f0-9]{16})$/), + parentSampled: undefined, + }); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': `${traceparentData?.traceId}-${traceparentData?.parentSpanId}`, + baggage: expect.stringContaining(`sentry-trace_id=${traceparentData?.traceId}`), + traceparent: `00-${traceparentData?.traceId}-${traceparentData?.parentSpanId}-00`, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/init.js new file mode 100644 index 000000000000..cb785bbf7fda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + propagateTraceparent: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/subject.js new file mode 100644 index 000000000000..45b43bdfafa9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/subject.js @@ -0,0 +1,9 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site.example/1'); +xhr.setRequestHeader('X-Test-Header', 'existing-header'); +xhr.setRequestHeader('sentry-trace', '123-abc-1'); +xhr.setRequestHeader('baggage', ' sentry-release=1.1.1, sentry-trace_id=123'); +xhr.setRequestHeader('traceparent', '00-123-abc-01'); + +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/test.ts new file mode 100644 index 000000000000..a9ce2f6de060 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers-propagateTraceparent/test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + "instrumentation doesn't override manually added traceparent header, if `propagateTraceparent` is true", + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/1'); + + await page.goto(url); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + expect(requestHeaders).toMatchObject({ + 'sentry-trace': '123-abc-1', + baggage: 'sentry-release=1.1.1, sentry-trace_id=123', + 'x-test-header': 'existing-header', + traceparent: '00-123-abc-01', + }); + }, +); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 65561c29e9de..b4e4f24d3b90 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -4,7 +4,7 @@ import type { ClientOptions, Event, EventHint, - Options, + Options as CoreOptions, ParameterizedString, Scope, SeverityLevel, @@ -31,13 +31,7 @@ type BrowserSpecificOptions = BrowserClientReplayOptions & BrowserClientProfilingOptions & { /** If configured, this URL will be used as base URL for lazy loading integration. */ cdnBaseUrl?: string; - }; -/** - * Configuration options for the Sentry Browser SDK. - * @see @sentry/core Options for more information. - */ -export type BrowserOptions = Options & - BrowserSpecificOptions & { + /** * Important: Only set this option if you know what you are doing! * @@ -56,7 +50,26 @@ export type BrowserOptions = Options & * @default false */ skipBrowserExtensionCheck?: boolean; + + /** + * If set to `true`, the SDK propagates the W3C `traceparent` header to any outgoing requests, + * in addition to the `sentry-trace` and `baggage` headers. Use the {@link CoreOptions.tracePropagationTargets} + * option to control to which outgoing requests the header will be attached. + * + * **Important:** If you set this option to `true`, make sure that you configured your servers' + * CORS settings to allow the `traceparent` header. Otherwise, requests might get blocked. + * + * @see https://www.w3.org/TR/trace-context/ + * + * @default false + */ + propagateTraceparent?: boolean; }; +/** + * Configuration options for the Sentry Browser SDK. + * @see @sentry/core Options for more information. + */ +export type BrowserOptions = CoreOptions & BrowserSpecificOptions; /** * Configuration options for the Sentry Browser SDK Client class diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 0756da3ccb2a..d046793b42a1 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -24,6 +24,7 @@ import { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY, } from '@sentry-internal/browser-utils'; +import type { BrowserClient } from '../client'; import { WINDOW } from '../helpers'; import { resourceTimingToSpanAttributes } from './resource-timing'; @@ -136,6 +137,8 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial = {}; + const propagateTraceparent = (client as BrowserClient).getOptions().propagateTraceparent; + if (traceFetch) { // Keeping track of http requests, whose body payloads resolved later than the initial resolved request // e.g. streaming using server sent events (SSE) @@ -166,7 +169,9 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { - const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { + propagateTraceparent, + }); if (handlerData.response && handlerData.fetchData.__span) { responseToSpanId.set(handlerData.response, handlerData.fetchData.__span); @@ -194,7 +199,14 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { - const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = xhrCallback( + handlerData, + shouldCreateSpan, + shouldAttachHeadersWithTargets, + spans, + propagateTraceparent, + ); + if (createdSpan) { if (enableHTTPTimings) { addHTTPTimings(createdSpan); @@ -298,11 +310,12 @@ export function shouldAttachHeaders( * * @returns Span if a span was created, otherwise void. */ -export function xhrCallback( +function xhrCallback( handlerData: HandlerDataXhr, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, + propagateTraceparent?: boolean, ): Span | undefined { const xhr = handlerData.xhr; const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY]; @@ -366,6 +379,7 @@ export function xhrCallback( // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred hasSpansEnabled() && hasParent ? span : undefined, + propagateTraceparent, ); } @@ -377,11 +391,15 @@ export function xhrCallback( return span; } -function addTracingHeadersToXhrRequest(xhr: SentryWrappedXMLHttpRequest, span?: Span): void { - const { 'sentry-trace': sentryTrace, baggage } = getTraceData({ span }); +function addTracingHeadersToXhrRequest( + xhr: SentryWrappedXMLHttpRequest, + span?: Span, + propagateTraceparent?: boolean, +): void { + const { 'sentry-trace': sentryTrace, baggage, traceparent } = getTraceData({ span, propagateTraceparent }); if (sentryTrace) { - setHeaderOnXhr(xhr, sentryTrace, baggage); + setHeaderOnXhr(xhr, sentryTrace, baggage, traceparent); } } @@ -389,17 +407,22 @@ function setHeaderOnXhr( xhr: SentryWrappedXMLHttpRequest, sentryTraceHeader: string, sentryBaggageHeader: string | undefined, + traceparentHeader: string | undefined, ): void { const originalHeaders = xhr.__sentry_xhr_v3__?.request_headers; - if (originalHeaders?.['sentry-trace']) { + if (originalHeaders?.['sentry-trace'] || !xhr.setRequestHeader) { // bail if a sentry-trace header is already set return; } try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - xhr.setRequestHeader!('sentry-trace', sentryTraceHeader); + xhr.setRequestHeader('sentry-trace', sentryTraceHeader); + + if (traceparentHeader && !originalHeaders?.['traceparent']) { + xhr.setRequestHeader('traceparent', traceparentHeader); + } + if (sentryBaggageHeader) { // only add our headers if // - no pre-existing baggage header exists @@ -409,8 +432,7 @@ function setHeaderOnXhr( // From MDN: "If this method is called several times with the same header, the values are merged into one single request header." // We can therefore simply set a baggage header without checking what was there before // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - xhr.setRequestHeader!('baggage', sentryBaggageHeader); + xhr.setRequestHeader('baggage', sentryBaggageHeader); } } } catch { diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 298a2693d096..1674a96d1937 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -17,6 +17,10 @@ class MockClient implements Partial { // Mock addEventProcessor function this.addEventProcessor = vi.fn(); } + // @ts-expect-error not returning options for the test + public getOptions() { + return {}; + } } describe('instrumentOutgoingRequests', () => { diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 7f15a7fc1759..66c9f559f29c 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -98,7 +98,9 @@ const _fetchIntegration = ((options: Partial = {}) => { return; } - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, 'auto.http.fetch'); + instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { + spanOrigin: 'auto.http.fetch', + }); if (breadcrumbs) { createBreadcrumb(handlerData); diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 2a8f9cf6e718..03cdbb9bf5a5 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -58,7 +58,7 @@ describe('WinterCGFetch instrumentation', () => { expect.any(Function), expect.any(Function), expect.any(Object), - 'auto.http.fetch', + { spanOrigin: 'auto.http.fetch' }, ); const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 502e24711994..501c29b4ea10 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -21,6 +21,39 @@ type PolymorphicRequestHeaders = get: (key: string) => string | null | undefined; }; +interface InstrumentFetchRequestOptions { + spanOrigin?: SpanOrigin; + propagateTraceparent?: boolean; +} + +/** + * Create and track fetch request spans for usage in combination with `addFetchInstrumentationHandler`. + * + * @deprecated pass an options object instead of the spanOrigin parameter + * + * @returns Span if a span was created, otherwise void. + */ +export function instrumentFetchRequest( + handlerData: HandlerDataFetch, + shouldCreateSpan: (url: string) => boolean, + shouldAttachHeaders: (url: string) => boolean, + spans: Record, + spanOrigin: SpanOrigin, +): Span | undefined; +/** + * Create and track fetch request spans for usage in combination with `addFetchInstrumentationHandler`. + * + * @returns Span if a span was created, otherwise void. + */ +export function instrumentFetchRequest( + handlerData: HandlerDataFetch, + shouldCreateSpan: (url: string) => boolean, + shouldAttachHeaders: (url: string) => boolean, + spans: Record, + // eslint-disable-next-line @typescript-eslint/unified-signatures -- needed because the other overload is deprecated + instrumentFetchRequestOptions: InstrumentFetchRequestOptions, +): Span | undefined; + /** * Create and track fetch request spans for usage in combination with `addFetchInstrumentationHandler`. * @@ -31,7 +64,7 @@ export function instrumentFetchRequest( shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, - spanOrigin: SpanOrigin = 'auto.http.browser', + spanOriginOrOptions?: SpanOrigin | InstrumentFetchRequestOptions, ): Span | undefined { if (!handlerData.fetchData) { return undefined; @@ -55,6 +88,12 @@ export function instrumentFetchRequest( return undefined; } + // Backwards-compatible with the old signature. Needed to introduce the combined optional parameter + // to avoid API breakage for anyone calling this function with the optional spanOrigin parameter + // TODO (v11): remove this backwards-compatible code and only accept the options parameter + const { spanOrigin = 'auto.http.browser', propagateTraceparent = false } = + typeof spanOriginOrOptions === 'object' ? spanOriginOrOptions : { spanOrigin: spanOriginOrOptions }; + const hasParent = !!getActiveSpan(); const span = @@ -77,6 +116,7 @@ export function instrumentFetchRequest( // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred hasSpansEnabled() && hasParent ? span : undefined, + propagateTraceparent, ); if (headers) { // Ensure this is actually set, if no options have been passed previously @@ -121,10 +161,12 @@ export function _addTracingHeadersToFetchRequest( | PolymorphicRequestHeaders; }, span?: Span, + propagateTraceparent?: boolean, ): PolymorphicRequestHeaders | undefined { - const traceHeaders = getTraceData({ span }); + const traceHeaders = getTraceData({ span, propagateTraceparent }); const sentryTrace = traceHeaders['sentry-trace']; const baggage = traceHeaders.baggage; + const traceparent = traceHeaders.traceparent; // Nothing to do, when we return undefined here, the original headers will be used if (!sentryTrace) { @@ -143,6 +185,10 @@ export function _addTracingHeadersToFetchRequest( newHeaders.set('sentry-trace', sentryTrace); } + if (propagateTraceparent && traceparent && !newHeaders.get('traceparent')) { + newHeaders.set('traceparent', traceparent); + } + if (baggage) { const prevBaggageHeader = newHeaders.get('baggage'); @@ -161,6 +207,10 @@ export function _addTracingHeadersToFetchRequest( newHeaders.push(['sentry-trace', sentryTrace]); } + if (propagateTraceparent && traceparent && !originalHeaders.find(header => header[0] === 'traceparent')) { + newHeaders.push(['traceparent', traceparent]); + } + const prevBaggageHeaderWithSentryValues = originalHeaders.find( header => header[0] === 'baggage' && baggageHeaderHasSentryBaggageValues(header[1]), ); @@ -174,8 +224,9 @@ export function _addTracingHeadersToFetchRequest( return newHeaders as PolymorphicRequestHeaders; } else { const existingSentryTraceHeader = 'sentry-trace' in originalHeaders ? originalHeaders['sentry-trace'] : undefined; - + const existingTraceparentHeader = 'traceparent' in originalHeaders ? originalHeaders.traceparent : undefined; const existingBaggageHeader = 'baggage' in originalHeaders ? originalHeaders.baggage : undefined; + const newBaggageHeaders: string[] = existingBaggageHeader ? Array.isArray(existingBaggageHeader) ? [...existingBaggageHeader] @@ -192,11 +243,21 @@ export function _addTracingHeadersToFetchRequest( newBaggageHeaders.push(baggage); } - return { + const newHeaders: { + 'sentry-trace': string; + baggage: string | undefined; + traceparent?: string; + } = { ...(originalHeaders as Exclude), 'sentry-trace': (existingSentryTraceHeader as string | undefined) ?? sentryTrace, baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined, }; + + if (propagateTraceparent && traceparent && !existingTraceparentHeader) { + newHeaders.traceparent = traceparent; + } + + return newHeaders; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6385a75687f7..4447eea4dae0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,9 @@ export { consoleIntegration } from './integrations/console'; export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; export { profiler } from './profiling'; +// eslint thinks the entire function is deprecated (while only one overload is actually deprecated) +// Therefore: +// eslint-disable-next-line deprecation/deprecation export { instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; @@ -342,7 +345,7 @@ export type { Extra, Extras } from './types-hoist/extra'; export type { Integration, IntegrationFn } from './types-hoist/integration'; export type { Mechanism } from './types-hoist/mechanism'; export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocation } from './types-hoist/misc'; -export type { ClientOptions, Options } from './types-hoist/options'; +export type { ClientOptions, CoreOptions as Options } from './types-hoist/options'; export type { Package } from './types-hoist/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types-hoist/polymorphics'; export type { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 0cc9fe2630fe..5cba3ff3dfb8 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -3,7 +3,7 @@ import { getClient } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Event, EventHint } from './types-hoist/event'; import type { Integration, IntegrationFn } from './types-hoist/integration'; -import type { Options } from './types-hoist/options'; +import type { CoreOptions } from './types-hoist/options'; import { debug } from './utils/debug-logger'; export const installedIntegrations: string[] = []; @@ -42,7 +42,9 @@ function filterDuplicates(integrations: Integration[]): Integration[] { } /** Gets integrations to install */ -export function getIntegrationsToSetup(options: Pick): Integration[] { +export function getIntegrationsToSetup( + options: Pick, +): Integration[] { const defaultIntegrations = options.defaultIntegrations || []; const userIntegrations = options.integrations; diff --git a/packages/core/src/tracing/sampling.ts b/packages/core/src/tracing/sampling.ts index 27b32970d74c..54cfdab59766 100644 --- a/packages/core/src/tracing/sampling.ts +++ b/packages/core/src/tracing/sampling.ts @@ -1,5 +1,5 @@ import { DEBUG_BUILD } from '../debug-build'; -import type { Options } from '../types-hoist/options'; +import type { CoreOptions } from '../types-hoist/options'; import type { SamplingContext } from '../types-hoist/samplingcontext'; import { debug } from '../utils/debug-logger'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; @@ -12,7 +12,7 @@ import { parseSampleRate } from '../utils/parseSampleRate'; * sent to Sentry. */ export function sampleSpan( - options: Pick, + options: Pick, samplingContext: SamplingContext, sampleRand: number, ): [sampled: boolean, sampleRate?: number, localSampleRateWasApplied?: boolean] { diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 142313b76c25..92603bb0242d 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -286,7 +286,7 @@ export interface ClientOptions +export interface CoreOptions extends Omit>, 'integrations' | 'transport' | 'stackParser'> { /** * If this is set to false, default integrations will not be added, otherwise this will internally be set to the diff --git a/packages/core/src/types-hoist/tracing.ts b/packages/core/src/types-hoist/tracing.ts index e1dcfef96c6a..60e59cdafa43 100644 --- a/packages/core/src/types-hoist/tracing.ts +++ b/packages/core/src/types-hoist/tracing.ts @@ -59,4 +59,5 @@ export interface PropagationContext { export interface SerializedTraceData { 'sentry-trace'?: string; baggage?: string; + traceparent?: string; } diff --git a/packages/core/src/utils/hasSpansEnabled.ts b/packages/core/src/utils/hasSpansEnabled.ts index 26a71eb7ca0b..cfa447fd86bd 100644 --- a/packages/core/src/utils/hasSpansEnabled.ts +++ b/packages/core/src/utils/hasSpansEnabled.ts @@ -1,5 +1,5 @@ import { getClient } from '../currentScopes'; -import type { Options } from '../types-hoist/options'; +import type { CoreOptions } from '../types-hoist/options'; // Treeshakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean | undefined; @@ -21,7 +21,7 @@ declare const __SENTRY_TRACING__: boolean | undefined; * If this option is not provided, the function will use the current client's options. */ export function hasSpansEnabled( - maybeOptions?: Pick | undefined, + maybeOptions?: Pick | undefined, ): boolean { if (typeof __SENTRY_TRACING__ === 'boolean' && !__SENTRY_TRACING__) { return false; diff --git a/packages/core/src/utils/sdkMetadata.ts b/packages/core/src/utils/sdkMetadata.ts index 437668a9e7f4..714a022689e3 100644 --- a/packages/core/src/utils/sdkMetadata.ts +++ b/packages/core/src/utils/sdkMetadata.ts @@ -1,4 +1,4 @@ -import type { Options } from '../types-hoist/options'; +import type { CoreOptions } from '../types-hoist/options'; import { SDK_VERSION } from '../utils/version'; /** @@ -14,7 +14,7 @@ import { SDK_VERSION } from '../utils/version'; * @param options SDK options object that gets mutated * @param names list of package names */ -export function applySdkMetadata(options: Options, name: string, names = [name], source = 'npm'): void { +export function applySdkMetadata(options: CoreOptions, name: string, names = [name], source = 'npm'): void { const metadata = options._metadata || {}; if (!metadata.sdk) { diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 1c1912d147a5..aa335dbd37bd 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -10,20 +10,25 @@ import type { SerializedTraceData } from '../types-hoist/tracing'; import { dynamicSamplingContextToSentryBaggageHeader } from './baggage'; import { debug } from './debug-logger'; import { getActiveSpan, spanToTraceHeader } from './spanUtils'; -import { generateSentryTraceHeader, TRACEPARENT_REGEXP } from './tracing'; +import { extractTraceparentData, generateSentryTraceHeader, TRACEPARENT_REGEXP } from './tracing'; /** * Extracts trace propagation data from the current span or from the client's scope (via transaction or propagation - * context) and serializes it to `sentry-trace` and `baggage` values to strings. These values can be used to propagate + * context) and serializes it to `sentry-trace` and `baggage` values. These values can be used to propagate * a trace via our tracing Http headers or Html `` tags. * * This function also applies some validation to the generated sentry-trace and baggage values to ensure that * only valid strings are returned. * + * If (@param options.propagateTraceparent) is `true`, the function will also generate a `traceparent` value, + * following the W3C traceparent header format. + * * @returns an object with the tracing data values. The object keys are the name of the tracing key to be used as header * or meta tag name. */ -export function getTraceData(options: { span?: Span; scope?: Scope; client?: Client } = {}): SerializedTraceData { +export function getTraceData( + options: { span?: Span; scope?: Scope; client?: Client; propagateTraceparent?: boolean } = {}, +): SerializedTraceData { const client = options.client || getClient(); if (!isEnabled() || !client) { return {}; @@ -47,10 +52,19 @@ export function getTraceData(options: { span?: Span; scope?: Scope; client?: Cli return {}; } - return { + const traceData: SerializedTraceData = { 'sentry-trace': sentryTrace, baggage, }; + + if (options.propagateTraceparent) { + const traceparent = _sentryTraceToTraceParentHeader(sentryTrace); + if (traceparent) { + traceData.traceparent = traceparent; + } + } + + return traceData; } /** @@ -60,3 +74,23 @@ function scopeToTraceHeader(scope: Scope): string { const { traceId, sampled, propagationSpanId } = scope.getPropagationContext(); return generateSentryTraceHeader(traceId, propagationSpanId, sampled); } + +/** + * Builds a W3C traceparent header from the given sentry-trace header. + * + * Why parse that header and not create traceparent from primitives? + * We want these two headers to always have the same ids. The easiest way to do this is to take + * one of them as the source of truth (sentry-trace) and derive the other from it. + * + * Most importantly, this guarantees parentSpanId consistency between sentry-trace and traceparent + * in tracing without performance (TwP) mode, where we always generate a random parentSpanId. + * + * Exported for testing + */ +export function _sentryTraceToTraceParentHeader(sentryTrace: string): string | undefined { + const { traceId, parentSpanId, parentSampled } = extractTraceparentData(sentryTrace) || {}; + if (!traceId || !parentSpanId) { + return undefined; + } + return `00-${traceId}-${parentSpanId}-${parentSampled ? '01' : '00'}`; +} diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index 509fff5acde0..0310cc8640e6 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -20,6 +20,9 @@ export const TRACEPARENT_REGEXP = new RegExp( /** * Extract transaction context data from a `sentry-trace` header. * + * This is terrible naming but the function has nothing to do with the W3C traceparent header. + * It can only parse the `sentry-trace` header and extract the "trace parent" data. + * * @param traceparent Traceparent string * * @returns Object containing data from the header, or undefined if traceparent string is malformed diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index d10bf1e3b592..37764cfc94a3 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -15,6 +15,7 @@ import { import { getAsyncContextStrategy } from '../../../src/asyncContext'; import { freezeDscOnSpan } from '../../../src/tracing/dynamicSamplingContext'; import type { Span } from '../../../src/types-hoist/span'; +import { _sentryTraceToTraceParentHeader } from '../../../src/utils/traceData'; import type { TestClientOptions } from '../../mocks/client'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -311,4 +312,71 @@ describe('getTraceData', () => { expect(traceData).toEqual({}); }); + + it('returns traceparent from span if propagateTraceparent is true', () => { + setupClient(); + + const span = new SentrySpan({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + sampled: true, + }); + + withActiveSpan(span, () => { + const data = getTraceData({ propagateTraceparent: true }); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + traceparent: '00-12345678901234567890123456789012-1234567890123456-01', + }); + }); + }); + + it('returns traceparent from scope in TwP config if propagateTraceparent is true', () => { + setupClient(); + + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789099', + sampled: undefined, + sampleRand: 0.44, + }); + + const traceData = getTraceData({ propagateTraceparent: true }); + + expect(traceData.traceparent).toBeDefined(); + expect(traceData.traceparent).toMatch(/00-12345678901234567890123456789099-[0-9a-f]{16}-00/); + }); +}); + +describe('_sentryTraceToTraceParentHeader', () => { + it('returns positively sampled traceparent header for sentry-trace with positive sampling decision', () => { + const traceparent = _sentryTraceToTraceParentHeader('12345678901234567890123456789012-1234567890123456-1'); + expect(traceparent).toBe('00-12345678901234567890123456789012-1234567890123456-01'); + }); + + it('returns negatively sampled traceparent header for sentry-trace with negative sampling decision', () => { + const traceparent = _sentryTraceToTraceParentHeader('12345678901234567890123456789012-1234567890123456-0'); + expect(traceparent).toBe('00-12345678901234567890123456789012-1234567890123456-00'); + }); + + it('returns negatively sampled traceparent header for sentry-trace with no/deferred sampling decision', () => { + const traceparent = _sentryTraceToTraceParentHeader('12345678901234567890123456789012-1234567890123456'); + expect(traceparent).toBe('00-12345678901234567890123456789012-1234567890123456-00'); + }); + + it.each([ + '12345678901234567890123456789012--0', + '-12345678901234567890123456789012-0', + '--1', + '0', + '1', + '', + '00-12345678901234567890123456789012-1234567890123456-01', + '00-12345678901234567890123456789012-1234567890123456-00', + ])('returns undefined if the sentry-trace header is invalid (%s)', sentryTrace => { + const traceparent = _sentryTraceToTraceParentHeader(sentryTrace); + expect(traceparent).toBeUndefined(); + }); }); diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index e200caef8e44..29a46e37da21 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -98,13 +98,9 @@ const _winterCGFetch = ((options: Partial = {}) => { return; } - instrumentFetchRequest( - handlerData, - _shouldCreateSpan, - _shouldAttachTraceData, - spans, - 'auto.http.wintercg_fetch', - ); + instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { + spanOrigin: 'auto.http.wintercg_fetch', + }); if (breadcrumbs) { createBreadcrumb(handlerData); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 7dc67d0131ea..cb5506a71e05 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -59,7 +59,7 @@ describe('WinterCGFetch instrumentation', () => { expect.any(Function), expect.any(Function), expect.any(Object), - 'auto.http.wintercg_fetch', + { spanOrigin: 'auto.http.wintercg_fetch' }, ); const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; From 9a376608d3868b40ad413646a31f6e3c32c746e8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 4 Sep 2025 13:31:48 +0200 Subject: [PATCH 4/6] feat(nextjs): Use compiler hook for uploading turbopack sourcemaps (#17352) --- packages/nextjs/package.json | 3 +- .../src/config/getBuildPluginOptions.ts | 97 +++ .../config/handleRunAfterProductionCompile.ts | 59 ++ packages/nextjs/src/config/types.ts | 12 + packages/nextjs/src/config/util.ts | 37 + .../nextjs/src/config/withSentryConfig.ts | 56 +- .../test/config/getBuildPluginOptions.test.ts | 432 +++++++++++ .../handleRunAfterProductionCompile.test.ts | 284 ++++++++ packages/nextjs/test/config/util.test.ts | 170 +++++ .../test/config/withSentryConfig.test.ts | 672 ++++++++++++++++++ yarn.lock | 113 +-- 11 files changed, 1892 insertions(+), 43 deletions(-) create mode 100644 packages/nextjs/src/config/getBuildPluginOptions.ts create mode 100644 packages/nextjs/src/config/handleRunAfterProductionCompile.ts create mode 100644 packages/nextjs/test/config/getBuildPluginOptions.test.ts create mode 100644 packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts create mode 100644 packages/nextjs/test/config/util.test.ts diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index da762d5bb5ae..3404c2b2d3ec 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -79,13 +79,14 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@rollup/plugin-commonjs": "28.0.1", + "@sentry/bundler-plugin-core": "^4.3.0", + "@sentry/webpack-plugin": "^4.3.0", "@sentry-internal/browser-utils": "10.9.0", "@sentry/core": "10.9.0", "@sentry/node": "10.9.0", "@sentry/opentelemetry": "10.9.0", "@sentry/react": "10.9.0", "@sentry/vercel-edge": "10.9.0", - "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts new file mode 100644 index 000000000000..3dfef3bbad08 --- /dev/null +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -0,0 +1,97 @@ +import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin-core'; +import * as path from 'path'; +import type { SentryBuildOptions } from './types'; + +/** + * Get Sentry Build Plugin options for the runAfterProductionCompile hook. + */ +export function getBuildPluginOptions({ + sentryBuildOptions, + releaseName, + distDirAbsPath, +}: { + sentryBuildOptions: SentryBuildOptions; + releaseName: string | undefined; + distDirAbsPath: string; +}): SentryBuildPluginOptions { + const sourcemapUploadAssets: string[] = []; + const sourcemapUploadIgnore: string[] = []; + + const filesToDeleteAfterUpload: string[] = []; + + // We need to convert paths to posix because Glob patterns use `\` to escape + // glob characters. This clashes with Windows path separators. + // See: https://www.npmjs.com/package/glob + const normalizedDistDirAbsPath = distDirAbsPath.replace(/\\/g, '/'); + + sourcemapUploadAssets.push( + path.posix.join(normalizedDistDirAbsPath, '**'), // Next.js build output + ); + if (sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload) { + filesToDeleteAfterUpload.push( + path.posix.join(normalizedDistDirAbsPath, '**', '*.js.map'), + path.posix.join(normalizedDistDirAbsPath, '**', '*.mjs.map'), + path.posix.join(normalizedDistDirAbsPath, '**', '*.cjs.map'), + ); + } + + return { + authToken: sentryBuildOptions.authToken, + headers: sentryBuildOptions.headers, + org: sentryBuildOptions.org, + project: sentryBuildOptions.project, + telemetry: sentryBuildOptions.telemetry, + debug: sentryBuildOptions.debug, + errorHandler: sentryBuildOptions.errorHandler, + reactComponentAnnotation: { + ...sentryBuildOptions.reactComponentAnnotation, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, + }, + silent: sentryBuildOptions.silent, + url: sentryBuildOptions.sentryUrl, + sourcemaps: { + disable: sentryBuildOptions.sourcemaps?.disable, + rewriteSources(source) { + if (source.startsWith('webpack://_N_E/')) { + return source.replace('webpack://_N_E/', ''); + } else if (source.startsWith('webpack://')) { + return source.replace('webpack://', ''); + } else { + return source; + } + }, + assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, + ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, + filesToDeleteAfterUpload, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, + }, + release: + releaseName !== undefined + ? { + inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. + name: releaseName, + create: sentryBuildOptions.release?.create, + finalize: sentryBuildOptions.release?.finalize, + dist: sentryBuildOptions.release?.dist, + vcsRemote: sentryBuildOptions.release?.vcsRemote, + setCommits: sentryBuildOptions.release?.setCommits, + deploy: sentryBuildOptions.release?.deploy, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, + } + : { + inject: false, + create: false, + finalize: false, + }, + bundleSizeOptimizations: { + ...sentryBuildOptions.bundleSizeOptimizations, + }, + _metaOptions: { + loggerPrefixOverride: '[@sentry/nextjs]', + telemetry: { + metaFramework: 'nextjs', + }, + }, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions, + }; +} diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts new file mode 100644 index 000000000000..01979b497c72 --- /dev/null +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -0,0 +1,59 @@ +import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core'; +import { loadModule } from '@sentry/core'; +import { getBuildPluginOptions } from './getBuildPluginOptions'; +import type { SentryBuildOptions } from './types'; + +/** + * This function is called by Next.js after the production build is complete. + * It is used to upload sourcemaps to Sentry. + */ +export async function handleRunAfterProductionCompile( + { releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' }, + sentryBuildOptions: SentryBuildOptions, +): Promise { + // We don't want to do anything for webpack at this point because the plugin already handles this + // TODO: Actually implement this for webpack as well + if (buildTool === 'webpack') { + return; + } + + if (sentryBuildOptions.debug) { + // eslint-disable-next-line no-console + console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + } + + const { createSentryBuildPluginManager } = + loadModule<{ createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType }>( + '@sentry/bundler-plugin-core', + module, + ) ?? {}; + + if (!createSentryBuildPluginManager) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + return; + } + + const sentryBuildPluginManager = createSentryBuildPluginManager( + getBuildPluginOptions({ + sentryBuildOptions, + releaseName, + distDirAbsPath: distDir, + }), + { + buildTool, + loggerPrefix: '[@sentry/nextjs]', + }, + ); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + await sentryBuildPluginManager.injectDebugIds([distDir]); + await sentryBuildPluginManager.uploadSourcemaps([distDir], { + // We don't want to prepare the artifacts because we injected debug ids manually before + prepareArtifacts: false, + }); + await sentryBuildPluginManager.deleteArtifacts(); +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 18cdc2d38cfc..1ca5eaa6bab0 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -52,6 +52,9 @@ export type NextConfigObject = { env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 turbopack?: TurbopackOptions; + compiler?: { + runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise | void; + }; }; export type SentryBuildOptions = { @@ -504,6 +507,15 @@ export type SentryBuildOptions = { * Use with caution in production environments. */ _experimental?: Partial<{ + /** + * When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads + * into a single operation after turbopack builds complete, reducing build time. + * + * When false, use the traditional approach of uploading sourcemaps during each webpack build. + * + * @default false + */ + useRunAfterProductionCompileHook?: boolean; thirdPartyOriginStackFrames: boolean; }>; }; diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index a88e68a57135..a5def59a66fe 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -1,3 +1,4 @@ +import { parseSemver } from '@sentry/core'; import * as fs from 'fs'; import { sync as resolveSync } from 'resolve'; @@ -27,3 +28,39 @@ function resolveNextjsPackageJson(): string | undefined { return undefined; } } + +/** + * Checks if the current Next.js version supports the runAfterProductionCompile hook. + * This hook was introduced in Next.js 15.4.1. (https://github.com/vercel/next.js/pull/77345) + * + * @returns true if Next.js version is 15.4.1 or higher + */ +export function supportsProductionCompileHook(): boolean { + const version = getNextjsVersion(); + if (!version) { + return false; + } + + const { major, minor, patch } = parseSemver(version); + + if (major === undefined || minor === undefined || patch === undefined) { + return false; + } + + if (major > 15) { + return true; + } + + // For major version 15, check if it's 15.4.1 or higher + if (major === 15) { + if (minor > 4) { + return true; + } + if (minor === 4 && patch >= 1) { + return true; + } + return false; + } + + return false; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index d1117855f06b..4558e5349c5a 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -5,6 +5,7 @@ import { getSentryRelease } from '@sentry/node'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { handleRunAfterProductionCompile } from './handleRunAfterProductionCompile'; import { createRouteManifest } from './manifest/createRouteManifest'; import type { RouteManifest } from './manifest/types'; import { constructTurbopackConfig } from './turbopack'; @@ -14,7 +15,7 @@ import type { NextConfigObject, SentryBuildOptions, } from './types'; -import { getNextjsVersion } from './util'; +import { getNextjsVersion, supportsProductionCompileHook } from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -293,6 +294,59 @@ function getFinalConfigObject( } } + if (userSentryOptions?._experimental?.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) { + if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { + incomingUserNextConfigObject.compiler ??= {}; + incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { + await handleRunAfterProductionCompile( + { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + userSentryOptions, + ); + }; + } else if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') { + incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy( + incomingUserNextConfigObject.compiler.runAfterProductionCompile, + { + async apply(target, thisArg, argArray) { + const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; + await target.apply(thisArg, argArray); + await handleRunAfterProductionCompile( + { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + userSentryOptions, + ); + }, + }, + ); + } else { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', + ); + } + } + + // Enable source maps for turbopack builds + if (isTurbopackSupported && isTurbopack && !userSentryOptions.sourcemaps?.disable) { + // Only set if not already configured by user + if (incomingUserNextConfigObject.productionBrowserSourceMaps === undefined) { + // eslint-disable-next-line no-console + console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); + incomingUserNextConfigObject.productionBrowserSourceMaps = true; + + // Enable source map deletion if not explicitly disabled + if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload === undefined) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + userSentryOptions.sourcemaps = { + ...userSentryOptions.sourcemaps, + deleteSourcemapsAfterUpload: true, + }; + } + } + } + return { ...incomingUserNextConfigObject, ...(nextMajor && nextMajor >= 15 diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts new file mode 100644 index 000000000000..1120084ec76e --- /dev/null +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getBuildPluginOptions } from '../../src/config/getBuildPluginOptions'; +import type { SentryBuildOptions } from '../../src/config/types'; + +describe('getBuildPluginOptions', () => { + const mockReleaseName = 'test-release-1.0.0'; + const mockDistDirAbsPath = '/path/to/.next'; + + describe('basic functionality', () => { + it('returns correct build plugin options with minimal configuration', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + authToken: 'test-token', + org: 'test-org', + project: 'test-project', + sourcemaps: { + assets: ['/path/to/.next/**'], + ignore: [], + filesToDeleteAfterUpload: [], + rewriteSources: expect.any(Function), + }, + release: { + inject: false, + name: mockReleaseName, + create: undefined, + finalize: undefined, + }, + _metaOptions: { + loggerPrefixOverride: '[@sentry/nextjs]', + telemetry: { + metaFramework: 'nextjs', + }, + }, + bundleSizeOptimizations: {}, + }); + }); + + it('normalizes Windows paths to posix for glob patterns', () => { + const windowsPath = 'C:\\Users\\test\\.next'; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: windowsPath, + }); + + expect(result.sourcemaps?.assets).toEqual(['C:/Users/test/.next/**']); + }); + }); + + describe('sourcemap configuration', () => { + it('configures file deletion when deleteSourcemapsAfterUpload is enabled', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + '/path/to/.next/**/*.js.map', + '/path/to/.next/**/*.mjs.map', + '/path/to/.next/**/*.cjs.map', + ]); + }); + + it('does not configure file deletion when deleteSourcemapsAfterUpload is disabled', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: false, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([]); + }); + + it('uses custom sourcemap assets when provided', () => { + const customAssets = ['custom/path/**', 'another/path/**']; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + assets: customAssets, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.assets).toEqual(customAssets); + }); + + it('uses custom sourcemap ignore patterns when provided', () => { + const customIgnore = ['**/vendor/**', '**/node_modules/**']; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + ignore: customIgnore, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.ignore).toEqual(customIgnore); + }); + + it('disables sourcemaps when disable flag is set', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + disable: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps?.disable).toBe(true); + }); + }); + + describe('source rewriting functionality', () => { + it('rewrites webpack sources correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + const rewriteSources = result.sourcemaps?.rewriteSources; + expect(rewriteSources).toBeDefined(); + + if (rewriteSources) { + // Test webpack://_N_E/ prefix removal + expect(rewriteSources('webpack://_N_E/src/pages/index.js', {})).toBe('src/pages/index.js'); + + // Test general webpack:// prefix removal + expect(rewriteSources('webpack://project/src/components/Button.js', {})).toBe( + 'project/src/components/Button.js', + ); + + // Test no rewriting for normal paths + expect(rewriteSources('src/utils/helpers.js', {})).toBe('src/utils/helpers.js'); + expect(rewriteSources('./components/Layout.tsx', {})).toBe('./components/Layout.tsx'); + } + }); + }); + + describe('release configuration', () => { + it('configures release with injection disabled when release name is provided', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + release: { + create: true, + finalize: true, + dist: 'production', + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + name: mockReleaseName, + create: true, + finalize: true, + dist: 'production', + }); + }); + + it('configures release as disabled when no release name is provided', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: undefined, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + create: false, + finalize: false, + }); + }); + + it('merges webpack plugin release options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + release: { + create: true, + vcsRemote: 'origin', + }, + unstable_sentryWebpackPluginOptions: { + release: { + setCommits: { + auto: true, + }, + deploy: { + env: 'production', + }, + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + // The unstable_sentryWebpackPluginOptions.release is spread at the end and may override base properties + expect(result.release).toHaveProperty('setCommits.auto', true); + expect(result.release).toHaveProperty('deploy.env', 'production'); + }); + }); + + describe('react component annotation', () => { + it('merges react component annotation options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + reactComponentAnnotation: { + enabled: true, + }, + unstable_sentryWebpackPluginOptions: { + reactComponentAnnotation: { + enabled: false, // This will override the base setting + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + // The unstable options override the base options - in this case enabled should be false + expect(result.reactComponentAnnotation).toHaveProperty('enabled', false); + }); + }); + + describe('other configuration options', () => { + it('passes through all standard configuration options', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + headers: { 'Custom-Header': 'value' }, + telemetry: false, + debug: true, + errorHandler: vi.fn(), + silent: true, + sentryUrl: 'https://custom.sentry.io', + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + authToken: 'test-token', + headers: { 'Custom-Header': 'value' }, + org: 'test-org', + project: 'test-project', + telemetry: false, + debug: true, + errorHandler: sentryBuildOptions.errorHandler, + silent: true, + url: 'https://custom.sentry.io', + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }); + }); + + it('merges unstable webpack plugin options correctly', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + unstable_sentryWebpackPluginOptions: { + applicationKey: 'test-app-key', + sourcemaps: { + disable: false, + }, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result).toMatchObject({ + applicationKey: 'test-app-key', + sourcemaps: expect.objectContaining({ + disable: false, + }), + }); + }); + }); + + describe('edge cases', () => { + it('handles undefined release name gracefully', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: undefined, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.release).toMatchObject({ + inject: false, + create: false, + finalize: false, + }); + }); + + it('handles empty sourcemaps configuration', () => { + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: {}, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + }); + + expect(result.sourcemaps).toMatchObject({ + disable: undefined, + assets: ['/path/to/.next/**'], + ignore: [], + filesToDeleteAfterUpload: [], + rewriteSources: expect.any(Function), + }); + }); + + it('handles complex nested path structures', () => { + const complexPath = '/very/deep/nested/path/with/multiple/segments/.next'; + const sentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, + }; + + const result = getBuildPluginOptions({ + sentryBuildOptions, + releaseName: mockReleaseName, + distDirAbsPath: complexPath, + }); + + expect(result.sourcemaps?.assets).toEqual([`${complexPath}/**`]); + expect(result.sourcemaps?.filesToDeleteAfterUpload).toEqual([ + `${complexPath}/**/*.js.map`, + `${complexPath}/**/*.mjs.map`, + `${complexPath}/**/*.cjs.map`, + ]); + }); + }); +}); diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts new file mode 100644 index 000000000000..22973cb6f15b --- /dev/null +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -0,0 +1,284 @@ +import { loadModule } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleRunAfterProductionCompile } from '../../src/config/handleRunAfterProductionCompile'; +import type { SentryBuildOptions } from '../../src/config/types'; + +vi.mock('@sentry/core', () => ({ + loadModule: vi.fn(), +})); + +vi.mock('../../src/config/getBuildPluginOptions', () => ({ + getBuildPluginOptions: vi.fn(() => ({ + org: 'test-org', + project: 'test-project', + sourcemaps: {}, + })), +})); + +describe('handleRunAfterProductionCompile', () => { + const mockCreateSentryBuildPluginManager = vi.fn(); + const mockSentryBuildPluginManager = { + telemetry: { + emitBundlerPluginExecutionSignal: vi.fn().mockResolvedValue(undefined), + }, + createRelease: vi.fn().mockResolvedValue(undefined), + injectDebugIds: vi.fn().mockResolvedValue(undefined), + uploadSourcemaps: vi.fn().mockResolvedValue(undefined), + deleteArtifacts: vi.fn().mockResolvedValue(undefined), + }; + + const mockSentryBuildOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSentryBuildPluginManager.mockReturnValue(mockSentryBuildPluginManager); + (loadModule as any).mockReturnValue({ + createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, + }); + }); + + describe('turbopack builds', () => { + it('executes all build steps for turbopack builds', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.createRelease).toHaveBeenCalledTimes(1); + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith(['/path/to/.next']); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith(['/path/to/.next'], { + prepareArtifacts: false, + }); + expect(mockSentryBuildPluginManager.deleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('calls createSentryBuildPluginManager with correct options', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'test-org', + project: 'test-project', + sourcemaps: expect.any(Object), + }), + { + buildTool: 'turbopack', + loggerPrefix: '[@sentry/nextjs]', + }, + ); + }); + + it('handles debug mode correctly', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const debugOptions = { + ...mockSentryBuildOptions, + debug: true, + }; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + debugOptions, + ); + + expect(consoleSpy).toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + + consoleSpy.mockRestore(); + }); + }); + + describe('webpack builds', () => { + it('skips execution for webpack builds', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + mockSentryBuildOptions, + ); + + expect(loadModule).not.toHaveBeenCalled(); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + }); + + it('does not log debug message for webpack builds when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const debugOptions = { + ...mockSentryBuildOptions, + debug: true, + }; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + debugOptions, + ); + + expect(consoleSpy).not.toHaveBeenCalledWith('[@sentry/nextjs] Running runAfterProductionCompile logic.'); + + consoleSpy.mockRestore(); + }); + }); + + describe('error handling', () => { + it('handles missing bundler plugin core gracefully', async () => { + (loadModule as any).mockReturnValue(null); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('handles missing createSentryBuildPluginManager export gracefully', async () => { + (loadModule as any).mockReturnValue({}); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.', + ); + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('propagates errors from build plugin manager operations', async () => { + const mockError = new Error('Test error'); + mockSentryBuildPluginManager.createRelease.mockRejectedValue(mockError); + + await expect( + handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ), + ).rejects.toThrow('Test error'); + }); + }); + + describe('step execution order', () => { + it('executes build steps in correct order', async () => { + const executionOrder: string[] = []; + + mockSentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal.mockImplementation(async () => { + executionOrder.push('telemetry'); + }); + mockSentryBuildPluginManager.createRelease.mockImplementation(async () => { + executionOrder.push('createRelease'); + }); + mockSentryBuildPluginManager.injectDebugIds.mockImplementation(async () => { + executionOrder.push('injectDebugIds'); + }); + mockSentryBuildPluginManager.uploadSourcemaps.mockImplementation(async () => { + executionOrder.push('uploadSourcemaps'); + }); + mockSentryBuildPluginManager.deleteArtifacts.mockImplementation(async () => { + executionOrder.push('deleteArtifacts'); + }); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(executionOrder).toEqual([ + 'telemetry', + 'createRelease', + 'injectDebugIds', + 'uploadSourcemaps', + 'deleteArtifacts', + ]); + }); + }); + + describe('path handling', () => { + it('correctly passes distDir to debug ID injection', async () => { + const customDistDir = '/custom/dist/path'; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: customDistDir, + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith([customDistDir]); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith([customDistDir], { + prepareArtifacts: false, + }); + }); + + it('works with relative paths', async () => { + const relativeDistDir = '.next'; + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: relativeDistDir, + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(mockSentryBuildPluginManager.injectDebugIds).toHaveBeenCalledWith([relativeDistDir]); + expect(mockSentryBuildPluginManager.uploadSourcemaps).toHaveBeenCalledWith([relativeDistDir], { + prepareArtifacts: false, + }); + }); + }); +}); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts new file mode 100644 index 000000000000..01b94480ea5f --- /dev/null +++ b/packages/nextjs/test/config/util.test.ts @@ -0,0 +1,170 @@ +import * as fs from 'fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as util from '../../src/config/util'; + +// Mock fs to control what getNextjsVersion reads +vi.mock('fs'); + +describe('util', () => { + describe('supportsProductionCompileHook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('supported versions', () => { + it('returns true for Next.js 15.4.1', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); + + const result = util.supportsProductionCompileHook(); + expect(result).toBe(true); + }); + + it('returns true for Next.js 15.4.2', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 15.5.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.5.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 16.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '16.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for Next.js 17.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '17.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for supported canary versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-canary.42' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for supported rc versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-rc.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + }); + + describe('unsupported versions', () => { + it('returns false for Next.js 15.4.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 15.3.9', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.9' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 15.0.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.0.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for Next.js 14.2.0', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '14.2.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for unsupported canary versions', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0-canary.42' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for invalid version strings', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: 'invalid.version' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('handles versions with build metadata', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1+build.123' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('handles versions with pre-release identifiers', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-alpha.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns false for versions missing patch number', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns false for versions missing minor number', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + + describe('version boundary tests', () => { + it('returns false for 15.4.0 (just below threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + + it('returns true for 15.4.1 (exact threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns true for 15.4.2 (just above threshold)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); + + expect(util.supportsProductionCompileHook()).toBe(true); + }); + + it('returns false for 15.3.999 (high patch but wrong minor)', () => { + const mockReadFileSync = fs.readFileSync as any; + mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.999' })); + + expect(util.supportsProductionCompileHook()).toBe(false); + }); + }); + }); +}); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 3b872d810c49..9303223c97bc 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -269,6 +269,429 @@ describe('withSentryConfig', () => { }); }); + describe('turbopack sourcemap configuration', () => { + const originalTurbopack = process.env.TURBOPACK; + + afterEach(() => { + vi.restoreAllMocks(); + process.env.TURBOPACK = originalTurbopack; + }); + + it('enables productionBrowserSourceMaps for supported turbopack builds when sourcemaps are not disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable productionBrowserSourceMaps when sourcemaps are disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: { + disable: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('does not enable productionBrowserSourceMaps when turbopack is not enabled', () => { + delete process.env.TURBOPACK; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('does not enable productionBrowserSourceMaps when turbopack version is not supported', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.2.0'); // unsupported version + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('preserves user-configured productionBrowserSourceMaps setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMaps = { + ...exportedNextConfig, + productionBrowserSourceMaps: false, // user explicitly disabled + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMaps); + + expect(finalConfig.productionBrowserSourceMaps).toBe(false); + }); + + it('preserves user-configured productionBrowserSourceMaps: true setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMaps = { + ...exportedNextConfig, + productionBrowserSourceMaps: true, // user explicitly enabled + }; + + const sentryOptions = { + sourcemaps: { + disable: true, // Sentry disabled, but user wants Next.js sourcemaps + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMaps, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('automatically enables deleteSourcemapsAfterUpload for turbopack builds when not explicitly set', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to ensure it gets auto-enabled + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // no deleteSourcemapsAfterUpload setting + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + // Both productionBrowserSourceMaps and deleteSourcemapsAfterUpload should be enabled + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + }); + + it('preserves explicitly configured deleteSourcemapsAfterUpload setting', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + sourcemaps: { + deleteSourcemapsAfterUpload: false, // user wants to keep sourcemaps + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps.deleteSourcemapsAfterUpload).toBe(false); + }); + + it('does not modify deleteSourcemapsAfterUpload when sourcemaps are disabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + sourcemaps: { + disable: true, + }, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('does not enable deleteSourcemapsAfterUpload when user pre-configured productionBrowserSourceMaps: true', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMapsPreEnabled = { + ...exportedNextConfig, + productionBrowserSourceMaps: true, // User already enabled + }; + + const sentryOptions = { + sourcemaps: {}, // no explicit deleteSourcemapsAfterUpload setting + }; + + materializeFinalNextConfig(configWithSourceMapsPreEnabled, undefined, sentryOptions); + + // Should NOT automatically enable deletion because productionBrowserSourceMaps was already set by user + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('does not enable sourcemaps or deletion when user explicitly sets productionBrowserSourceMaps: false', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithSourceMapsDisabled = { + ...exportedNextConfig, + productionBrowserSourceMaps: false, // User explicitly disabled + }; + + const sentryOptions = { + sourcemaps: {}, // no explicit deleteSourcemapsAfterUpload setting + }; + + const finalConfig = materializeFinalNextConfig(configWithSourceMapsDisabled, undefined, sentryOptions); + + // Should NOT modify productionBrowserSourceMaps or enable deletion when user explicitly set to false + expect(finalConfig.productionBrowserSourceMaps).toBe(false); + expect(sentryOptions.sourcemaps).not.toHaveProperty('deleteSourcemapsAfterUpload'); + }); + + it('logs correct message when enabling sourcemaps for turbopack', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + materializeFinalNextConfig(cleanConfig); + + expect(consoleSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.', + ); + + consoleSpy.mockRestore(); + }); + + it('warns about automatic sourcemap deletion for turbopack builds', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // triggers automatic deletion + }; + + materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + + consoleWarnSpy.mockRestore(); + }); + + describe('version compatibility', () => { + it('enables sourcemaps for Next.js 15.3.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('enables sourcemaps for Next.js 15.4.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('enables sourcemaps for Next.js 16.0.0', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps for Next.js 15.2.9', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.2.9'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('enables sourcemaps for supported canary versions', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.28'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps for unsupported canary versions', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.27'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles undefined sourcemaps option', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = {}; // no sourcemaps property + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('handles empty sourcemaps object', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + sourcemaps: {}, // empty object + }; + + materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + }); + + it('works when TURBOPACK env var is truthy string', () => { + process.env.TURBOPACK = 'true'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + }); + + it('does not enable sourcemaps when TURBOPACK env var is falsy', () => { + process.env.TURBOPACK = ''; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); + }); + + it('works correctly with tunnel route configuration', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; + + const sentryOptions = { + tunnelRoute: '/custom-tunnel', + sourcemaps: {}, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + expect(finalConfig.rewrites).toBeInstanceOf(Function); + }); + + it('works correctly with custom release configuration', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + // Clear environment variable to test custom release name + const originalSentryRelease = process.env.SENTRY_RELEASE; + delete process.env.SENTRY_RELEASE; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.env; + delete cleanConfig.productionBrowserSourceMaps; // Ensure it gets auto-enabled + + const sentryOptions = { + release: { + name: 'custom-release-1.0.0', + }, + sourcemaps: {}, + }; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); + expect(finalConfig.env).toHaveProperty('_sentryRelease', 'custom-release-1.0.0'); + + // Restore original env var + if (originalSentryRelease) { + process.env.SENTRY_RELEASE = originalSentryRelease; + } + }); + + it('does not interfere with other Next.js configuration options', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithOtherOptions = { + ...exportedNextConfig, + assetPrefix: 'https://cdn.example.com', + basePath: '/app', + distDir: 'custom-dist', + }; + + const finalConfig = materializeFinalNextConfig(configWithOtherOptions); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.assetPrefix).toBe('https://cdn.example.com'); + expect(finalConfig.basePath).toBe('/app'); + expect(finalConfig.distDir).toBe('custom-dist'); + }); + + it('works correctly when turbopack config already exists', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const configWithTurbopack = { + ...exportedNextConfig, + turbopack: { + resolveAlias: { + '@': './src', + }, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithTurbopack); + + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.turbopack?.resolveAlias).toEqual({ '@': './src' }); + }); + }); + }); + describe('release injection behavior', () => { afterEach(() => { vi.restoreAllMocks(); @@ -335,4 +758,253 @@ describe('withSentryConfig', () => { expect(finalConfig.env).toHaveProperty('_sentryRelease', 'env-release-1.5.0'); }); }); + + describe('runAfterProductionCompile hook integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets up runAfterProductionCompile hook when experimental flag is enabled and version is supported', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('does not set up hook when experimental flag is disabled', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: false, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when Next.js version is not supported', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('preserves existing runAfterProductionCompile hook using proxy', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const originalHook = vi.fn().mockResolvedValue(undefined); + const configWithExistingHook = { + ...exportedNextConfig, + compiler: { + runAfterProductionCompile: originalHook, + }, + }; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithExistingHook, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + expect(finalConfig.compiler?.runAfterProductionCompile).not.toBe(originalHook); + }); + + it('warns when existing runAfterProductionCompile is not a function', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const configWithInvalidHook = { + ...exportedNextConfig, + compiler: { + runAfterProductionCompile: 'invalid-hook' as any, + }, + }; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + materializeFinalNextConfig(configWithInvalidHook, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('creates compiler object when it does not exist', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const configWithoutCompiler = { ...exportedNextConfig }; + delete configWithoutCompiler.compiler; + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(configWithoutCompiler, undefined, sentryOptions); + + expect(finalConfig.compiler).toBeDefined(); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('works with turbopack builds when TURBOPACK env is set', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + + delete process.env.TURBOPACK; + }); + + it('works with webpack builds when TURBOPACK env is not set', () => { + delete process.env.TURBOPACK; + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + }); + + describe('experimental flag handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('respects useRunAfterProductionCompileHook: true', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + }); + + it('respects useRunAfterProductionCompileHook: false', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: false, + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when experimental flag is undefined', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + // useRunAfterProductionCompileHook not specified + }, + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('does not set up hook when _experimental is undefined', () => { + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + // no _experimental property + }; + + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.compiler; + + const finalConfig = materializeFinalNextConfig(cleanConfig, undefined, sentryOptions); + + expect(finalConfig.compiler?.runAfterProductionCompile).toBeUndefined(); + }); + + it('combines experimental flag with other configurations correctly', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + + const sentryOptions = { + _experimental: { + useRunAfterProductionCompileHook: true, + }, + sourcemaps: {}, + tunnelRoute: '/tunnel', + }; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + // Should have both turbopack sourcemap config AND runAfterProductionCompile hook + expect(finalConfig.productionBrowserSourceMaps).toBe(true); + expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); + expect(finalConfig.rewrites).toBeInstanceOf(Function); + + delete process.env.TURBOPACK; + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 225ff96259e7..c3a81b3cc097 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4718,6 +4718,18 @@ resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -6946,6 +6958,16 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.1.tgz#371415afc602f6b2ba0987b51123bd34d1603193" integrity sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA== +"@sentry/babel-plugin-component-annotate@4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz#6c616e6d645f49f15f83b891ef42a795ba4dbb3f" + integrity sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ== + +"@sentry/babel-plugin-component-annotate@4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" + integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== + "@sentry/bundler-plugin-core@4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.0.tgz#c1b2f7a890a44e5ac5decc984a133aacf6147dd4" @@ -6974,6 +6996,34 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.2.0.tgz#b607937f7cd0a769aa26974c4af3fca94abad63f" + integrity sha512-EDG6ELSEN/Dzm4KUQOynoI2suEAdPdgwaBXVN4Ww705zdrYT79OGh51rkz74KGhovt7GukaPf0Z9LJwORXUbhg== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.2.0" + "@sentry/cli" "^2.51.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + +"@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" + integrity sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.3.0" + "@sentry/cli" "^2.51.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.52.0": version "2.52.0" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.52.0.tgz#05178cd819c2a33eb22a6e90bf7bb8f853f1b476" @@ -7050,12 +7100,12 @@ "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" -"@sentry/webpack-plugin@^4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.1.1.tgz#638c6b65cbc19b5027ffbb6bcd68094e0b0f82c6" - integrity sha512-2gFWcQMW1HdJDo/7rADeFs9crkH02l+mW4O1ORbxSjuegauyp1W8SBe7EfPoXbUmLdA3zwnpIxEXjjQpP5Etzg== +"@sentry/webpack-plugin@^4.1.1", "@sentry/webpack-plugin@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.3.0.tgz#a96db7d8ada8646ec3ffdec2a7db6143c8061e85" + integrity sha512-K4nU1SheK/tvyakBws2zfd+MN6hzmpW+wPTbSbDWn1+WL9+g9hsPh8hjFFiVe47AhhUoUZ3YgiH2HyeHXjHflA== dependencies: - "@sentry/bundler-plugin-core" "4.1.1" + "@sentry/bundler-plugin-core" "4.3.0" unplugin "1.0.1" uuid "^9.0.0" @@ -9410,7 +9460,7 @@ postcss "^8.4.47" source-map-js "^1.2.0" -"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.13", "@vue/compiler-sfc@^3.5.4": +"@vue/compiler-sfc@^3.4.15", "@vue/compiler-sfc@^3.5.4": version "3.5.17" resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz#c518871276e26593612bdab36f3f5bcd053b13bf" integrity sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww== @@ -13457,10 +13507,10 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -14205,9 +14255,6 @@ detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" integrity sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^7.0.1" detective-stylus@^4.0.0: version "4.0.0" @@ -14242,14 +14289,6 @@ detective-vue2@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/detective-vue2/-/detective-vue2-2.2.0.tgz#35fd1d39e261b064aca9fcaf20e136c76877482a" integrity sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA== - dependencies: - "@dependents/detective-less" "^5.0.1" - "@vue/compiler-sfc" "^3.5.13" - detective-es6 "^5.0.1" - detective-sass "^6.0.1" - detective-scss "^5.0.1" - detective-stylus "^5.0.1" - detective-typescript "^14.0.0" deterministic-object-hash@^1.3.1: version "1.3.1" @@ -16781,9 +16820,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== - dependencies: - node-domexception "^1.0.0" - web-streams-polyfill "^3.0.3" fflate@0.8.2, fflate@^0.8.2: version "0.8.2" @@ -17143,11 +17179,11 @@ foreach@^2.0.5: integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= foreground-child@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" - integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: - cross-spawn "^7.0.0" + cross-spawn "^7.0.6" signal-exit "^4.0.1" form-data@^4.0.0: @@ -19855,9 +19891,9 @@ jackspeak@^3.1.2: "@pkgjs/parseargs" "^0.11.0" jackspeak@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.0.tgz#c489c079f2b636dc4cbe9b0312a13ff1282e561b" - integrity sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw== + version "4.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== dependencies: "@isaacs/cliui" "^8.0.2" @@ -22128,11 +22164,11 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: brace-expansion "^2.0.1" minimatch@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" - integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + version "10.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== dependencies: - brace-expansion "^2.0.1" + "@isaacs/brace-expansion" "^5.0.0" minimatch@^7.4.1: version "7.4.6" @@ -22978,11 +23014,6 @@ node-cron@^3.0.3: dependencies: uuid "8.3.2" -node-domexception@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - node-fetch-native@^1.4.0, node-fetch-native@^1.6.3, node-fetch-native@^1.6.4, node-fetch-native@^1.6.6: version "1.6.6" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37" @@ -31088,7 +31119,7 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.1.1: +web-streams-polyfill@^3.1.1: version "3.3.3" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== From 9e70a5ac7cc3dd1bfa0e13790fcb159e0a8ad9af Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 4 Sep 2025 13:52:04 +0200 Subject: [PATCH 5/6] feat(core): Add tool calls attributes for Anthropic AI (#17478) This PR adds missing tool call attributes, we addgen_ai.response.tool_calls attribute for Anthropic AI, supporting both streaming and non-streaming requests. Core changes: Request Side - Capture available tools: - Extract tools extract from request params - Set gen_ai.request.available_tools attribute Response Side - Capture actual tool calls: - Extract from response.tool_calls - Set gen_ai.response.tool_calls attribute for both Streaming Support (in streaming.ts): - Accumulation of tool calls during streaming - Respects recordOutputs privacy setting --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../anthropic/scenario-stream-tools.mjs | 112 +++++++++++++++++ .../tracing/anthropic/scenario-tools.mjs | 64 ++++++++++ .../suites/tracing/anthropic/test.ts | 55 +++++++++ .../core/src/utils/anthropic-ai/constants.ts | 1 + packages/core/src/utils/anthropic-ai/index.ts | 21 +++- .../core/src/utils/anthropic-ai/streaming.ts | 115 ++++++++++++++++-- packages/core/src/utils/anthropic-ai/types.ts | 28 ++++- 7 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-tools.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-tools.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-tools.mjs new file mode 100644 index 000000000000..8d423fd0bbe0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream-tools.mjs @@ -0,0 +1,112 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +function createMockStreamEvents(model = 'claude-3-haiku-20240307') { + async function* generator() { + // initial message metadata with id/model and input tokens + yield { + type: 'content_block_start', + message: { + id: 'msg_stream_tool_1', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: 'end_turn', + usage: { input_tokens: 11 }, + }, + }; + + // streamed text + yield { type: 'content_block_delta', delta: { text: 'Starting tool...' } }; + + // tool_use streamed via partial json + yield { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool_weather_2', name: 'weather' }, + }; + yield { type: 'content_block_delta', index: 0, delta: { partial_json: '{"city":' } }; + yield { type: 'content_block_delta', index: 0, delta: { partial_json: '"Paris"}' } }; + yield { type: 'content_block_stop', index: 0 }; + + // more text + yield { type: 'content_block_delta', delta: { text: 'Done.' } }; + + // final usage + yield { type: 'message_delta', usage: { output_tokens: 9 } }; + } + return generator(); +} + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.messages = { + create: this._messagesCreate.bind(this), + stream: this._messagesStream.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + if (params?.stream) { + return createMockStreamEvents(params.model); + } + return { + id: 'msg_mock_no_stream', + type: 'message', + model: params.model, + role: 'assistant', + content: [{ type: 'text', text: 'No stream' }], + usage: { input_tokens: 2, output_tokens: 3 }, + }; + } + + async _messagesStream(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + return createMockStreamEvents(params?.model); + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient); + + // stream via create(stream:true) + const stream1 = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Need the weather' }], + tools: [ + { + name: 'weather', + description: 'Get weather', + input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + }, + ], + stream: true, + }); + for await (const _ of stream1) { + void _; + } + + // stream via messages.stream + const stream2 = await client.messages.stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Need the weather' }], + tools: [ + { + name: 'weather', + description: 'Get weather', + input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + }, + ], + }); + for await (const _ of stream2) { + void _; + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-tools.mjs new file mode 100644 index 000000000000..1637a77c9dd8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-tools.mjs @@ -0,0 +1,64 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 5)); + + return { + id: 'msg_mock_tool_1', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { type: 'text', text: 'Let me check the weather.' }, + { + type: 'tool_use', + id: 'tool_weather_1', + name: 'weather', + input: { city: 'Paris' }, + }, + { type: 'text', text: 'It is sunny.' }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 5, + output_tokens: 7, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient); + + await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the weather?' }], + tools: [ + { + name: 'weather', + description: 'Get the weather by city', + input_schema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + ], + }); + }); +} + +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 9b8c7219000d..35252f574003 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -293,4 +293,59 @@ describe('Anthropic integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_STREAM_SPANS_PII_TRUE }).start().completed(); }); }); + + // Non-streaming tool calls + available tools (PII true) + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('non-streaming sets available tools and tool calls with PII', async () => { + const EXPECTED_TOOLS_JSON = + '[{"name":"weather","description":"Get the weather by city","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]'; + const EXPECTED_TOOL_CALLS_JSON = + '[{"type":"tool_use","id":"tool_weather_1","name":"weather","input":{"city":"Paris"}}]'; + await createRunner() + .ignore('event') + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.request.available_tools': EXPECTED_TOOLS_JSON, + 'gen_ai.response.tool_calls': EXPECTED_TOOL_CALLS_JSON, + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }); + + // Streaming tool calls + available tools (PII true) + createEsmAndCjsTests(__dirname, 'scenario-stream-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('streaming sets available tools and tool calls with PII', async () => { + const EXPECTED_TOOLS_JSON = + '[{"name":"weather","description":"Get weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]'; + const EXPECTED_TOOL_CALLS_JSON = + '[{"type":"tool_use","id":"tool_weather_2","name":"weather","input":{"city":"Paris"}}]'; + await createRunner() + .ignore('event') + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + description: expect.stringContaining('stream-response'), + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.request.available_tools': EXPECTED_TOOLS_JSON, + 'gen_ai.response.tool_calls': EXPECTED_TOOL_CALLS_JSON, + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/utils/anthropic-ai/constants.ts b/packages/core/src/utils/anthropic-ai/constants.ts index 1e20745e0f1f..7e6c66196a82 100644 --- a/packages/core/src/utils/anthropic-ai/constants.ts +++ b/packages/core/src/utils/anthropic-ai/constants.ts @@ -9,4 +9,5 @@ export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ 'models.get', 'completions.create', 'models.retrieve', + 'beta.messages.create', ] as const; diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 2ed95be76843..c54fdc2a8a9c 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -8,6 +8,7 @@ import { ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PROMPT_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, @@ -19,6 +20,7 @@ import { GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; @@ -31,6 +33,7 @@ import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, + ContentBlock, } from './types'; import { shouldInstrument } from './utils'; @@ -46,6 +49,9 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; + if (params.tools && Array.isArray(params.tools)) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(params.tools); + } attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; @@ -96,10 +102,21 @@ function addResponseAttributes(span: Span, response: AnthropicAiResponse, record if (Array.isArray(response.content)) { span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content - .map((item: { text: string | undefined }) => item.text) - .filter((text): text is string => text !== undefined) + .map((item: ContentBlock) => item.text) + .filter(text => !!text) .join(''), }); + + const toolCalls: Array = []; + + for (const item of response.content) { + if (item.type === 'tool_use' || item.type === 'server_tool_use') { + toolCalls.push(item); + } + } + if (toolCalls.length > 0) { + span.setAttributes({ [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls) }); + } } } // Completions.create diff --git a/packages/core/src/utils/anthropic-ai/streaming.ts b/packages/core/src/utils/anthropic-ai/streaming.ts index 8ebbfc0b42cd..c48dc8a6def7 100644 --- a/packages/core/src/utils/anthropic-ai/streaming.ts +++ b/packages/core/src/utils/anthropic-ai/streaming.ts @@ -7,6 +7,7 @@ import { GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { setTokenUsageAttributes } from '../ai/utils'; import type { AnthropicAiStreamingEvent } from './types'; @@ -32,6 +33,17 @@ interface StreamingState { cacheCreationInputTokens: number | undefined; /** Number of cache read input tokens used. */ cacheReadInputTokens: number | undefined; + /** Accumulated tool calls (finalized) */ + toolCalls: Array>; + /** In-progress tool call blocks keyed by index */ + activeToolBlocks: Record< + number, + { + id?: string; + name?: string; + inputJsonParts: string[]; + } + >; } /** @@ -43,12 +55,7 @@ interface StreamingState { * @returns Whether an error occurred */ -function isErrorEvent( - event: AnthropicAiStreamingEvent, - state: StreamingState, - recordOutputs: boolean, - span: Span, -): boolean { +function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { if ('type' in event && typeof event.type === 'string') { // If the event is an error, set the span status and capture the error // These error events are not rejected by the API by default, but are sent as metadata of the response @@ -69,11 +76,6 @@ function isErrorEvent( }); return true; } - - if (recordOutputs && event.type === 'content_block_delta') { - const text = event.delta?.text; - if (text) state.responseTexts.push(text); - } } return false; } @@ -110,6 +112,77 @@ function handleMessageMetadata(event: AnthropicAiStreamingEvent, state: Streamin } } +/** + * Handle start of a content block (e.g., tool_use) + */ +function handleContentBlockStart(event: AnthropicAiStreamingEvent, state: StreamingState): void { + if (event.type !== 'content_block_start' || typeof event.index !== 'number' || !event.content_block) return; + if (event.content_block.type === 'tool_use' || event.content_block.type === 'server_tool_use') { + state.activeToolBlocks[event.index] = { + id: event.content_block.id, + name: event.content_block.name, + inputJsonParts: [], + }; + } +} + +/** + * Handle deltas of a content block, including input_json_delta for tool_use + */ +function handleContentBlockDelta( + event: AnthropicAiStreamingEvent, + state: StreamingState, + recordOutputs: boolean, +): void { + if (event.type !== 'content_block_delta' || !event.delta) return; + + // Accumulate tool_use input JSON deltas only when we have an index and an active tool block + if ( + typeof event.index === 'number' && + 'partial_json' in event.delta && + typeof event.delta.partial_json === 'string' + ) { + const active = state.activeToolBlocks[event.index]; + if (active) { + active.inputJsonParts.push(event.delta.partial_json); + } + } + + // Accumulate streamed response text regardless of index + if (recordOutputs && typeof event.delta.text === 'string') { + state.responseTexts.push(event.delta.text); + } +} + +/** + * Handle stop of a content block; finalize tool_use entries + */ +function handleContentBlockStop(event: AnthropicAiStreamingEvent, state: StreamingState): void { + if (event.type !== 'content_block_stop' || typeof event.index !== 'number') return; + + const active = state.activeToolBlocks[event.index]; + if (!active) return; + + const raw = active.inputJsonParts.join(''); + let parsedInput: unknown; + + try { + parsedInput = raw ? JSON.parse(raw) : {}; + } catch { + parsedInput = { __unparsed: raw }; + } + + state.toolCalls.push({ + type: 'tool_use', + id: active.id, + name: active.name, + input: parsedInput, + }); + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.activeToolBlocks[event.index]; +} + /** * Processes an event * @param event - The event to process @@ -128,10 +201,19 @@ function processEvent( return; } - const isError = isErrorEvent(event, state, recordOutputs, span); + const isError = isErrorEvent(event, span); if (isError) return; handleMessageMetadata(event, state); + + // Tool call events are sent via 3 separate events: + // - content_block_start (start of the tool call) + // - content_block_delta (delta aka input of the tool call) + // - content_block_stop (end of the tool call) + // We need to handle them all to capture the full tool call. + handleContentBlockStart(event, state); + handleContentBlockDelta(event, state, recordOutputs); + handleContentBlockStop(event, state); } /** @@ -153,6 +235,8 @@ export async function* instrumentStream( completionTokens: undefined, cacheCreationInputTokens: undefined, cacheReadInputTokens: undefined, + toolCalls: [], + activeToolBlocks: {}, }; try { @@ -197,6 +281,13 @@ export async function* instrumentStream( }); } + // Set tool calls if any were captured + if (recordOutputs && state.toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(state.toolCalls), + }); + } + span.end(); } } diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/utils/anthropic-ai/types.ts index fd533b6795bc..6ab2e790e651 100644 --- a/packages/core/src/utils/anthropic-ai/types.ts +++ b/packages/core/src/utils/anthropic-ai/types.ts @@ -16,6 +16,17 @@ export type Message = { content: string | unknown[]; }; +export type ContentBlock = { + type: 'tool_use' | 'server_tool_use' | string; + text?: string; + /** Tool name when type is tool_use */ + name?: string; + /** Tool invocation id when type is tool_use */ + id?: string; + input?: Record; + tool_use_id?: string; +}; + export type AnthropicAiResponse = { [key: string]: unknown; // Allow for additional unknown properties id: string; @@ -23,7 +34,7 @@ export type AnthropicAiResponse = { created?: number; created_at?: number; // Available for Models.retrieve messages?: Array; - content?: string; // Available for Messages.create + content?: string | Array; // Available for Messages.create completion?: string; // Available for Completions.create input_tokens?: number; // Available for Models.countTokens usage?: { @@ -87,7 +98,14 @@ export type AnthropicAiMessage = { * Streaming event type for Anthropic AI */ export type AnthropicAiStreamingEvent = { - type: 'message_delta' | 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'error'; + type: + | 'message_start' + | 'message_delta' + | 'message_stop' + | 'content_block_start' + | 'content_block_delta' + | 'content_block_stop' + | 'error'; error?: { type: string; message: string; @@ -96,9 +114,15 @@ export type AnthropicAiStreamingEvent = { delta?: { type: unknown; text?: string; + /** Present for fine-grained tool streaming */ + partial_json?: string; + stop_reason?: string; + stop_sequence?: number; }; usage?: { output_tokens: number; // Final total output tokens; emitted on the last `message_delta` event }; message?: AnthropicAiMessage; + /** Present for fine-grained tool streaming */ + content_block?: ContentBlock; }; From 22c7722632dad5d6d7ea9066e31c8080948a8bc0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 4 Sep 2025 14:05:10 +0200 Subject: [PATCH 6/6] meta(changelog): Update changelog for 10.10.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2eb8aa6e45..810c2bba0d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.10.0 + +### Important Changes + +- **feat(browser): Add support for `propagateTraceparent` SDK option ([#17509](https://github.com/getsentry/sentry-javascript/pull/17509))** + +Adds support for a new browser SDK init option, `propagateTraceparent` for attaching a W3C compliant traceparent header to outgoing fetch and XHR requests, in addition to sentry-trace and baggage headers. More details can be found [here](https://develop.sentry.dev/sdk/telemetry/traces/#propagatetraceparent). + +- **feat(core): Add tool calls attributes for Anthropic AI ([#17478](https://github.com/getsentry/sentry-javascript/pull/17478))** + +Adds missing tool call attributes, we add gen_ai.response.tool_calls attribute for Anthropic AI, supporting both streaming and non-streaming requests. + +- **feat(nextjs): Use compiler hook for uploading turbopack sourcemaps ([#17352](https://github.com/getsentry/sentry-javascript/pull/17352))** + +Adds a new _experimental_ flag `_experimental.useRunAfterProductionCompileHook` to `withSentryConfig` for automatic source maps uploads when building a Next.js app with `next build --turbopack`. +When set we: + +- Automatically enable source map generation for turbopack client files (if not explicitly disabled) +- Upload generated source maps to Sentry at the end of the build by leveraging [a Next.js compiler hook](https://nextjs.org/docs/architecture/nextjs-compiler#runafterproductioncompile). + +### Other Changes + +- feat(feedback): Add more labels so people can configure Highlight and Hide labels ([#17513](https://github.com/getsentry/sentry-javascript/pull/17513)) +- fix(node): Add `origin` for OpenAI spans & test auto instrumentation ([#17519](https://github.com/getsentry/sentry-javascript/pull/17519)) + ## 10.9.0 ### Important Changes