Skip to content

Commit d729cdb

Browse files
authored
feat(nextjs): Add URL to server-side transaction events (#18230)
URLs were missing from server-side transaction events (server components, generation functions) in Next.js. This was previously removed in #18113 because we tried to synchronously access `params` and `searchParams`, which cause builds to crash. This PR approach adds the URL at runtime using a `preprocessEvent` hook as suggested. **Implementation** 1. Reads `http.target` (actual request path) and `next.route` (parameterized route) from the transaction's trace data 2. Extracts headers from the captured isolation scope's SDK processing metadata 3. Builds the full URL using the existing `getSanitizedRequestUrl()` utility 4. Adds it to `normalizedRequest.url` so the `requestDataIntegration` includes it in the event This works uniformly for both Webpack and Turbopack across all of our supported Next.js versions (13~16), I added missing tests for this case in the versions that did not have it. Fixes #18115
1 parent 9ce440c commit d729cdb

File tree

9 files changed

+243
-7
lines changed

9 files changed

+243
-7
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a transaction for a request to app router with URL', async ({ page }) => {
5+
const serverComponentTransactionPromise = waitForTransaction('nextjs-13', transactionEvent => {
6+
return (
7+
transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' &&
8+
transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42')
9+
);
10+
});
11+
12+
await page.goto('/parameterized/1337/beep/42');
13+
14+
const transactionEvent = await serverComponentTransactionPromise;
15+
16+
expect(transactionEvent.contexts?.trace).toEqual({
17+
data: expect.objectContaining({
18+
'sentry.op': 'http.server',
19+
'sentry.origin': 'auto',
20+
'sentry.sample_rate': 1,
21+
'sentry.source': 'route',
22+
'http.method': 'GET',
23+
'http.response.status_code': 200,
24+
'http.route': '/parameterized/[one]/beep/[two]',
25+
'http.status_code': 200,
26+
'http.target': '/parameterized/1337/beep/42',
27+
'otel.kind': 'SERVER',
28+
'next.route': '/parameterized/[one]/beep/[two]',
29+
}),
30+
op: 'http.server',
31+
origin: 'auto',
32+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
33+
status: 'ok',
34+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
35+
});
36+
37+
expect(transactionEvent.request).toMatchObject({
38+
url: expect.stringContaining('/parameterized/1337/beep/42'),
39+
});
40+
41+
// The transaction should not contain any spans with the same name as the transaction
42+
// e.g. "GET /parameterized/[one]/beep/[two]"
43+
expect(
44+
transactionEvent.spans?.filter(span => {
45+
return span.description === transactionEvent.transaction;
46+
}),
47+
).toHaveLength(0);
48+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a transaction for a request to app router with URL', async ({ page }) => {
5+
const serverComponentTransactionPromise = waitForTransaction('nextjs-15', transactionEvent => {
6+
return (
7+
transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' &&
8+
transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42')
9+
);
10+
});
11+
12+
await page.goto('/parameterized/1337/beep/42');
13+
14+
const transactionEvent = await serverComponentTransactionPromise;
15+
16+
expect(transactionEvent.contexts?.trace).toEqual({
17+
data: expect.objectContaining({
18+
'sentry.op': 'http.server',
19+
'sentry.origin': 'auto',
20+
'sentry.sample_rate': 1,
21+
'sentry.source': 'route',
22+
'http.method': 'GET',
23+
'http.response.status_code': 200,
24+
'http.route': '/parameterized/[one]/beep/[two]',
25+
'http.status_code': 200,
26+
'http.target': '/parameterized/1337/beep/42',
27+
'otel.kind': 'SERVER',
28+
'next.route': '/parameterized/[one]/beep/[two]',
29+
}),
30+
op: 'http.server',
31+
origin: 'auto',
32+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
33+
status: 'ok',
34+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
35+
});
36+
37+
expect(transactionEvent.request).toMatchObject({
38+
url: expect.stringContaining('/parameterized/1337/beep/42'),
39+
});
40+
41+
// The transaction should not contain any spans with the same name as the transaction
42+
// e.g. "GET /parameterized/[one]/beep/[two]"
43+
expect(
44+
transactionEvent.spans?.filter(span => {
45+
return span.description === transactionEvent.transaction;
46+
}),
47+
).toHaveLength(0);
48+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a transaction for a request to app router with URL', async ({ page }) => {
5+
const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => {
6+
return (
7+
transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' &&
8+
transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42')
9+
);
10+
});
11+
12+
await page.goto('/parameterized/1337/beep/42');
13+
14+
const transactionEvent = await serverComponentTransactionPromise;
15+
16+
expect(transactionEvent.contexts?.trace).toEqual({
17+
data: expect.objectContaining({
18+
'sentry.op': 'http.server',
19+
'sentry.origin': 'auto',
20+
'sentry.sample_rate': 1,
21+
'sentry.source': 'route',
22+
'http.method': 'GET',
23+
'http.response.status_code': 200,
24+
'http.route': '/parameterized/[one]/beep/[two]',
25+
'http.status_code': 200,
26+
'http.target': '/parameterized/1337/beep/42',
27+
'otel.kind': 'SERVER',
28+
'next.route': '/parameterized/[one]/beep/[two]',
29+
}),
30+
op: 'http.server',
31+
origin: 'auto',
32+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
33+
status: 'ok',
34+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
35+
});
36+
37+
expect(transactionEvent.request).toMatchObject({
38+
url: expect.stringContaining('/parameterized/1337/beep/42'),
39+
});
40+
41+
// The transaction should not contain any spans with the same name as the transaction
42+
// e.g. "GET /parameterized/[one]/beep/[two]"
43+
expect(
44+
transactionEvent.spans?.filter(span => {
45+
return span.description === transactionEvent.transaction;
46+
}),
47+
).toHaveLength(0);
48+
});

dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,8 @@ test('Sends a transaction for a request to app router', async ({ page }) => {
3434
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
3535
});
3636

37-
expect(transactionEvent.request).toEqual({
38-
cookies: {},
39-
headers: expect.objectContaining({
40-
'user-agent': expect.any(String),
41-
}),
37+
expect(transactionEvent.request).toMatchObject({
38+
url: expect.stringContaining('/server-component/parameter/1337/42'),
4239
});
4340

4441
// The transaction should not contain any spans with the same name as the transaction

dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn
6666
expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error');
6767

6868
expect(routehandlerError.request?.method).toBe('GET');
69-
// todo: make sure url is attached to request object
70-
// expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error');
69+
expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error');
7170

7271
expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error');
7372
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a transaction for a request to app router with URL', async ({ page }) => {
5+
const serverComponentTransactionPromise = waitForTransaction('nextjs-turbo', transactionEvent => {
6+
return (
7+
transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' &&
8+
transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42')
9+
);
10+
});
11+
12+
await page.goto('/parameterized/1337/beep/42');
13+
14+
const transactionEvent = await serverComponentTransactionPromise;
15+
16+
expect(transactionEvent.contexts?.trace).toEqual({
17+
data: expect.objectContaining({
18+
'sentry.op': 'http.server',
19+
'sentry.origin': 'auto',
20+
'sentry.sample_rate': 1,
21+
'sentry.source': 'route',
22+
'http.method': 'GET',
23+
'http.response.status_code': 200,
24+
'http.route': '/parameterized/[one]/beep/[two]',
25+
'http.status_code': 200,
26+
'http.target': '/parameterized/1337/beep/42',
27+
'otel.kind': 'SERVER',
28+
'next.route': '/parameterized/[one]/beep/[two]',
29+
}),
30+
op: 'http.server',
31+
origin: 'auto',
32+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
33+
status: 'ok',
34+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
35+
});
36+
37+
expect(transactionEvent.request).toMatchObject({
38+
url: expect.stringContaining('/parameterized/1337/beep/42'),
39+
});
40+
41+
// The transaction should not contain any spans with the same name as the transaction
42+
// e.g. "GET /parameterized/[one]/beep/[two]"
43+
expect(
44+
transactionEvent.spans?.filter(span => {
45+
return span.description === transactionEvent.transaction;
46+
}),
47+
).toHaveLength(0);
48+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Event } from '@sentry/core';
2+
import { getClient } from '@sentry/core';
3+
import { getSanitizedRequestUrl } from './urls';
4+
5+
/**
6+
* Sets the URL processing metadata for the event.
7+
*/
8+
export function setUrlProcessingMetadata(event: Event): void {
9+
// Skip if not a server-side transaction
10+
if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'http.server' || !event.contexts?.trace?.data) {
11+
return;
12+
}
13+
14+
// Only add URL if sendDefaultPii is enabled, as URLs may contain PII
15+
const client = getClient();
16+
if (!client?.getOptions().sendDefaultPii) {
17+
return;
18+
}
19+
20+
const traceData = event.contexts.trace.data;
21+
22+
// Get the route from trace data
23+
const componentRoute = traceData['next.route'] || traceData['http.route'];
24+
const httpTarget = traceData['http.target'] as string | undefined;
25+
26+
if (!componentRoute) {
27+
return;
28+
}
29+
30+
// Extract headers
31+
const isolationScopeData = event.sdkProcessingMetadata?.capturedSpanIsolationScope?.getScopeData();
32+
const headersDict = isolationScopeData?.sdkProcessingMetadata?.normalizedRequest?.headers;
33+
34+
const url = getSanitizedRequestUrl(componentRoute, undefined, headersDict, httpTarget?.toString());
35+
36+
// Add URL to the isolation scope's normalizedRequest so requestDataIntegration picks it up
37+
if (url && isolationScopeData?.sdkProcessingMetadata) {
38+
isolationScopeData.sdkProcessingMetadata.normalizedRequest =
39+
isolationScopeData.sdkProcessingMetadata.normalizedRequest || {};
40+
isolationScopeData.sdkProcessingMetadata.normalizedRequest.url = url;
41+
}
42+
}

packages/nextjs/src/edge/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-e
2222
import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes';
2323
import { isBuild } from '../common/utils/isBuild';
2424
import { flushSafelyWithTimeout } from '../common/utils/responseEnd';
25+
import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata';
2526
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';
2627

2728
export * from '@sentry/vercel-edge';
@@ -126,6 +127,8 @@ export function init(options: VercelEdgeOptions = {}): void {
126127
}
127128
}
128129
}
130+
131+
setUrlProcessingMetadata(event);
129132
});
130133

131134
client?.on('spanEnd', span => {

packages/nextjs/src/server/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from '../common/span-attributes-with-logic-attached';
4040
import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes';
4141
import { isBuild } from '../common/utils/isBuild';
42+
import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata';
4243
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';
4344

4445
export * from '@sentry/node';
@@ -391,6 +392,8 @@ export function init(options: NodeOptions): NodeClient | undefined {
391392
event.contexts.trace.parent_span_id = traceparentData.parentSpanId;
392393
}
393394
}
395+
396+
setUrlProcessingMetadata(event);
394397
});
395398

396399
if (process.env.NODE_ENV === 'development') {

0 commit comments

Comments
 (0)