Skip to content

Commit c4dcfa3

Browse files
authored
feat(nextjs): Add URL to tags of server components and generation functions issues (#16500)
Next.js’s uses an internal AsyncLocalStorage (ALS) to share minimal request context—it deliberately does not populate the full Request object or URL in ALS, so only headers (Referer, x-forwarded-host, x-forwarded-proto) are available at runtime in Server Components. To still capture a usable URL for metadata we use a three-step best-effort fallback: 1. `Referer` header
Use it as the fully qualified URL if present (note: this could be omitted by the browser). 2. Proxy headers + route
Reconstruct protocol://host from x-forwarded-proto / x-forwarded-host (or host), then append the normalized component route with substituted [param] values. Query strings and fragments are TBD as query params fetched from args are always undefined for some reason, this needs investigation 3. Span fallback
As a last resort, use the parent span’s http.target attribute (which contains the path and query) from the root HTTP span. <img width="1261" alt="Screenshot 2025-06-02 at 4 04 31 PM" src="https://github.com/user-attachments/assets/0cebe68a-89e7-4b61-9c58-962f8e70c7f9" /> closes: https://linear.app/getsentry/issue/JS-487/capture-requesturl-for-nextjs-server-spans
1 parent 66436bc commit c4dcfa3

File tree

5 files changed

+353
-3
lines changed

5 files changed

+353
-3
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => {
3939
headers: expect.objectContaining({
4040
'user-agent': expect.any(String),
4141
}),
42+
url: expect.stringContaining('/server-component/parameter/1337/42'),
4243
});
4344

4445
// The transaction should not contain any spans with the same name as the transaction
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { getSanitizedUrlStringFromUrlObject, parseStringToURLObject } from '@sentry/core';
2+
3+
type ComponentRouteParams = Record<string, string> | undefined;
4+
type HeadersDict = Record<string, string> | undefined;
5+
6+
const HeaderKeys = {
7+
FORWARDED_PROTO: 'x-forwarded-proto',
8+
FORWARDED_HOST: 'x-forwarded-host',
9+
HOST: 'host',
10+
REFERER: 'referer',
11+
} as const;
12+
13+
/**
14+
* Replaces route parameters in a path template with their values
15+
* @param path - The path template containing parameters in [paramName] format
16+
* @param params - Optional route parameters to replace in the template
17+
* @returns The path with parameters replaced
18+
*/
19+
export function substituteRouteParams(path: string, params?: ComponentRouteParams): string {
20+
if (!params || typeof params !== 'object') return path;
21+
22+
let resultPath = path;
23+
for (const [key, value] of Object.entries(params)) {
24+
resultPath = resultPath.split(`[${key}]`).join(encodeURIComponent(value));
25+
}
26+
return resultPath;
27+
}
28+
29+
/**
30+
* Normalizes a path by removing route groups
31+
* @param path - The path to normalize
32+
* @returns The normalized path
33+
*/
34+
export function sanitizeRoutePath(path: string): string {
35+
const cleanedSegments = path
36+
.split('/')
37+
.filter(segment => segment && !(segment.startsWith('(') && segment.endsWith(')')));
38+
39+
return cleanedSegments.length > 0 ? `/${cleanedSegments.join('/')}` : '/';
40+
}
41+
42+
/**
43+
* Constructs a full URL from the component route, parameters, and headers.
44+
*
45+
* @param componentRoute - The route template to construct the URL from
46+
* @param params - Optional route parameters to replace in the template
47+
* @param headersDict - Optional headers containing protocol and host information
48+
* @param pathname - Optional pathname coming from parent span "http.target"
49+
* @returns A sanitized URL string
50+
*/
51+
export function buildUrlFromComponentRoute(
52+
componentRoute: string,
53+
params?: ComponentRouteParams,
54+
headersDict?: HeadersDict,
55+
pathname?: string,
56+
): string {
57+
const parameterizedPath = substituteRouteParams(componentRoute, params);
58+
// If available, the pathname from the http.target of the HTTP request server span takes precedence over the parameterized path.
59+
// Spans such as generateMetadata and Server Component rendering are typically direct children of that span.
60+
const path = pathname ?? sanitizeRoutePath(parameterizedPath);
61+
62+
const protocol = headersDict?.[HeaderKeys.FORWARDED_PROTO];
63+
const host = headersDict?.[HeaderKeys.FORWARDED_HOST] || headersDict?.[HeaderKeys.HOST];
64+
65+
if (!protocol || !host) {
66+
return path;
67+
}
68+
69+
const fullUrl = `${protocol}://${host}${path}`;
70+
71+
const urlObject = parseStringToURLObject(fullUrl);
72+
if (!urlObject) {
73+
return path;
74+
}
75+
76+
return getSanitizedUrlStringFromUrlObject(urlObject);
77+
}
78+
79+
/**
80+
* Returns a sanitized URL string from the referer header if it exists and is valid.
81+
*
82+
* @param headersDict - Optional headers containing the referer
83+
* @returns A sanitized URL string or undefined if referer is missing/invalid
84+
*/
85+
export function extractSanitizedUrlFromRefererHeader(headersDict?: HeadersDict): string | undefined {
86+
const referer = headersDict?.[HeaderKeys.REFERER];
87+
if (!referer) {
88+
return undefined;
89+
}
90+
91+
try {
92+
const refererUrl = new URL(referer);
93+
return getSanitizedUrlStringFromUrlObject(refererUrl);
94+
} catch (error) {
95+
return undefined;
96+
}
97+
}
98+
99+
/**
100+
* Returns a sanitized URL string using the referer header if available,
101+
* otherwise constructs the URL from the component route, params, and headers.
102+
*
103+
* @param componentRoute - The route template to construct the URL from
104+
* @param params - Optional route parameters to replace in the template
105+
* @param headersDict - Optional headers containing protocol, host, and referer
106+
* @param pathname - Optional pathname coming from root span "http.target"
107+
* @returns A sanitized URL string
108+
*/
109+
export function getSanitizedRequestUrl(
110+
componentRoute: string,
111+
params?: ComponentRouteParams,
112+
headersDict?: HeadersDict,
113+
pathname?: string,
114+
): string {
115+
const refererUrl = extractSanitizedUrlFromRefererHeader(headersDict);
116+
if (refererUrl) {
117+
return refererUrl;
118+
}
119+
120+
return buildUrlFromComponentRoute(componentRoute, params, headersDict, pathname);
121+
}

packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
setCapturedScopesOnSpan,
1414
SPAN_STATUS_ERROR,
1515
SPAN_STATUS_OK,
16+
spanToJSON,
1617
startSpanManual,
1718
winterCGHeadersToDict,
1819
withIsolationScope,
@@ -22,7 +23,7 @@ import type { GenerationFunctionContext } from '../common/types';
2223
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
2324
import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached';
2425
import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils';
25-
26+
import { getSanitizedRequestUrl } from './utils/urls';
2627
/**
2728
* Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation.
2829
*/
@@ -44,14 +45,23 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => a
4445
}
4546

4647
const isolationScope = commonObjectToIsolationScope(headers);
48+
let pathname = undefined as string | undefined;
4749

4850
const activeSpan = getActiveSpan();
4951
if (activeSpan) {
5052
const rootSpan = getRootSpan(activeSpan);
5153
const { scope } = getCapturedScopesOnSpan(rootSpan);
5254
setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope);
55+
56+
const spanData = spanToJSON(rootSpan);
57+
58+
if (spanData.data && 'http.target' in spanData.data) {
59+
pathname = spanData.data['http.target'] as string;
60+
}
5361
}
5462

63+
const headersDict = headers ? winterCGHeadersToDict(headers) : undefined;
64+
5565
let data: Record<string, unknown> | undefined = undefined;
5666
if (getClient()?.getOptions().sendDefaultPii) {
5767
const props: unknown = args[0];
@@ -61,15 +71,19 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => a
6171
data = { params, searchParams };
6272
}
6373

64-
const headersDict = headers ? winterCGHeadersToDict(headers) : undefined;
65-
6674
return withIsolationScope(isolationScope, () => {
6775
return withScope(scope => {
6876
scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`);
6977

7078
isolationScope.setSDKProcessingMetadata({
7179
normalizedRequest: {
7280
headers: headersDict,
81+
url: getSanitizedRequestUrl(
82+
componentRoute,
83+
data?.params as Record<string, string> | undefined,
84+
headersDict,
85+
pathname,
86+
),
7387
} satisfies RequestEventData,
7488
});
7589

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
captureException,
44
getActiveSpan,
55
getCapturedScopesOnSpan,
6+
getClient,
67
getRootSpan,
78
handleCallbackErrors,
89
propagationContextFromHeaders,
@@ -12,6 +13,7 @@ import {
1213
setCapturedScopesOnSpan,
1314
SPAN_STATUS_ERROR,
1415
SPAN_STATUS_OK,
16+
spanToJSON,
1517
startSpanManual,
1618
vercelWaitUntil,
1719
winterCGHeadersToDict,
@@ -23,6 +25,7 @@ import type { ServerComponentContext } from '../common/types';
2325
import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached';
2426
import { flushSafelyWithTimeout } from './utils/responseEnd';
2527
import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils';
28+
import { getSanitizedRequestUrl } from './utils/urls';
2629

2730
/**
2831
* Wraps an `app` directory server component with Sentry error instrumentation.
@@ -41,18 +44,36 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
4144
const requestTraceId = getActiveSpan()?.spanContext().traceId;
4245
const isolationScope = commonObjectToIsolationScope(context.headers);
4346

47+
let pathname = undefined as string | undefined;
4448
const activeSpan = getActiveSpan();
4549
if (activeSpan) {
4650
const rootSpan = getRootSpan(activeSpan);
4751
const { scope } = getCapturedScopesOnSpan(rootSpan);
4852
setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope);
53+
54+
const spanData = spanToJSON(rootSpan);
55+
56+
if (spanData.data && 'http.target' in spanData.data) {
57+
pathname = spanData.data['http.target']?.toString();
58+
}
4959
}
5060

5161
const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined;
5262

63+
let params: Record<string, string> | undefined = undefined;
64+
65+
if (getClient()?.getOptions().sendDefaultPii) {
66+
const props: unknown = args[0];
67+
params =
68+
props && typeof props === 'object' && 'params' in props
69+
? (props.params as Record<string, string>)
70+
: undefined;
71+
}
72+
5373
isolationScope.setSDKProcessingMetadata({
5474
normalizedRequest: {
5575
headers: headersDict,
76+
url: getSanitizedRequestUrl(componentRoute, params, headersDict, pathname),
5677
} satisfies RequestEventData,
5778
});
5879

0 commit comments

Comments
 (0)