From 4e8d1a1d27acbc182a3115dab4d916e48835c908 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 13:50:48 +0200 Subject: [PATCH 1/4] feat(nextjs): add URL to transaction event --- .../common/utils/setUrlProcessingMetadata.ts | 35 +++++++++++++++++++ packages/nextjs/src/edge/index.ts | 3 ++ packages/nextjs/src/server/index.ts | 3 ++ 3 files changed, 41 insertions(+) create mode 100644 packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts new file mode 100644 index 000000000000..f5b4d9fd2318 --- /dev/null +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -0,0 +1,35 @@ +import type { Event } from '@sentry/core'; +import { getSanitizedRequestUrl } from './urls'; + +/** + * Sets the URL processing metadata for the event. + */ +export function setUrlProcessingMetadata(event: Event): void { + // Check if this is a server-side transaction + if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'http.server' || !event.contexts?.trace?.data) { + return; + } + + const traceData = event.contexts.trace.data; + + // Get the route from trace data + const componentRoute = traceData['next.route'] || traceData['http.route']; + const httpTarget = traceData['http.target'] as string | undefined; + + if (!componentRoute) { + return; + } + + // Extract headers from the captured isolation scope's SDK processing metadata + const isolationScopeData = event.sdkProcessingMetadata?.capturedSpanIsolationScope?.getScopeData(); + const headersDict = isolationScopeData?.sdkProcessingMetadata?.normalizedRequest?.headers; + + const url = getSanitizedRequestUrl(componentRoute, undefined, headersDict, httpTarget?.toString()); + + // Add URL to the isolation scope's normalizedRequest so requestDataIntegration picks it up + if (url && isolationScopeData?.sdkProcessingMetadata) { + isolationScopeData.sdkProcessingMetadata.normalizedRequest = + isolationScopeData.sdkProcessingMetadata.normalizedRequest || {}; + isolationScopeData.sdkProcessingMetadata.normalizedRequest.url = url; + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6ee523fe72dc..5fd92707b912 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -22,6 +22,7 @@ import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-e import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -126,6 +127,8 @@ export function init(options: VercelEdgeOptions = {}): void { } } } + + setUrlProcessingMetadata(event); }); client?.on('spanEnd', span => { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index aa6210c2ff6a..ce8ac7c56cea 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -39,6 +39,7 @@ import { } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/node'; @@ -391,6 +392,8 @@ export function init(options: NodeOptions): NodeClient | undefined { event.contexts.trace.parent_span_id = traceparentData.parentSpanId; } } + + setUrlProcessingMetadata(event); }); if (process.env.NODE_ENV === 'development') { From a006a36f6d4752eea0872e2e454ea8856c917dd4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 13:50:58 +0200 Subject: [PATCH 2/4] tests: added turbopack and webpack tests --- .../nextjs-15/tests/server-components.test.ts | 48 +++++++++++++++++++ .../nextjs-16/tests/server-components.test.ts | 48 +++++++++++++++++++ .../tests/server-components.test.ts | 7 +-- .../tests/app-router/route-handlers.test.ts | 3 +- .../app-router/server-components.test.ts | 48 +++++++++++++++++++ .../common/utils/setUrlProcessingMetadata.ts | 4 +- 6 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts new file mode 100644 index 000000000000..2f3488976d28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-15', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts new file mode 100644 index 000000000000..d5b1a00b30d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 25d84cdc28e1..eedb702715de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -34,11 +34,8 @@ test('Sends a transaction for a request to app router', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); - expect(transactionEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - 'user-agent': expect.any(String), - }), + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/server-component/parameter/1337/42'), }); // The transaction should not contain any spans with the same name as the transaction diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts index 544ba0084167..13f4f5fa4a58 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts @@ -66,8 +66,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error'); expect(routehandlerError.request?.method).toBe('GET'); - // todo: make sure url is attached to request object - // expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); + expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts new file mode 100644 index 000000000000..d3a6db69fcd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-turbo', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts index f5b4d9fd2318..dca23222291d 100644 --- a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -5,7 +5,7 @@ import { getSanitizedRequestUrl } from './urls'; * Sets the URL processing metadata for the event. */ export function setUrlProcessingMetadata(event: Event): void { - // Check if this is a server-side transaction + // Skip if not a server-side transaction if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'http.server' || !event.contexts?.trace?.data) { return; } @@ -20,7 +20,7 @@ export function setUrlProcessingMetadata(event: Event): void { return; } - // Extract headers from the captured isolation scope's SDK processing metadata + // Extract headers const isolationScopeData = event.sdkProcessingMetadata?.capturedSpanIsolationScope?.getScopeData(); const headersDict = isolationScopeData?.sdkProcessingMetadata?.normalizedRequest?.headers; From 0486820ae70813b97c34f8e75ad74977473bd86c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 14:28:20 +0200 Subject: [PATCH 3/4] test: added next-13 test --- .../tests/server/server-components.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts new file mode 100644 index 000000000000..c9e3a6ff588c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); From 6089a7062640191dbbe7e742ec8a4bf0507284d5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 18:14:22 +0200 Subject: [PATCH 4/4] fix: respect PII --- .../nextjs/src/common/utils/setUrlProcessingMetadata.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts index dca23222291d..0c7e0c3b33f2 100644 --- a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -1,4 +1,5 @@ import type { Event } from '@sentry/core'; +import { getClient } from '@sentry/core'; import { getSanitizedRequestUrl } from './urls'; /** @@ -10,6 +11,12 @@ export function setUrlProcessingMetadata(event: Event): void { return; } + // Only add URL if sendDefaultPii is enabled, as URLs may contain PII + const client = getClient(); + if (!client?.getOptions().sendDefaultPii) { + return; + } + const traceData = event.contexts.trace.data; // Get the route from trace data