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 498c9b969ed9..19bfeeec7fcf 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 @@ -39,6 +39,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => { headers: expect.objectContaining({ 'user-agent': expect.any(String), }), + 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/packages/nextjs/src/common/utils/urls.ts b/packages/nextjs/src/common/utils/urls.ts new file mode 100644 index 000000000000..d1274e1c35d9 --- /dev/null +++ b/packages/nextjs/src/common/utils/urls.ts @@ -0,0 +1,121 @@ +import { getSanitizedUrlStringFromUrlObject, parseStringToURLObject } from '@sentry/core'; + +type ComponentRouteParams = Record | undefined; +type HeadersDict = Record | undefined; + +const HeaderKeys = { + FORWARDED_PROTO: 'x-forwarded-proto', + FORWARDED_HOST: 'x-forwarded-host', + HOST: 'host', + REFERER: 'referer', +} as const; + +/** + * Replaces route parameters in a path template with their values + * @param path - The path template containing parameters in [paramName] format + * @param params - Optional route parameters to replace in the template + * @returns The path with parameters replaced + */ +export function substituteRouteParams(path: string, params?: ComponentRouteParams): string { + if (!params || typeof params !== 'object') return path; + + let resultPath = path; + for (const [key, value] of Object.entries(params)) { + resultPath = resultPath.split(`[${key}]`).join(encodeURIComponent(value)); + } + return resultPath; +} + +/** + * Normalizes a path by removing route groups + * @param path - The path to normalize + * @returns The normalized path + */ +export function sanitizeRoutePath(path: string): string { + const cleanedSegments = path + .split('/') + .filter(segment => segment && !(segment.startsWith('(') && segment.endsWith(')'))); + + return cleanedSegments.length > 0 ? `/${cleanedSegments.join('/')}` : '/'; +} + +/** + * Constructs a full URL from the component route, parameters, and headers. + * + * @param componentRoute - The route template to construct the URL from + * @param params - Optional route parameters to replace in the template + * @param headersDict - Optional headers containing protocol and host information + * @param pathname - Optional pathname coming from parent span "http.target" + * @returns A sanitized URL string + */ +export function buildUrlFromComponentRoute( + componentRoute: string, + params?: ComponentRouteParams, + headersDict?: HeadersDict, + pathname?: string, +): string { + const parameterizedPath = substituteRouteParams(componentRoute, params); + // If available, the pathname from the http.target of the HTTP request server span takes precedence over the parameterized path. + // Spans such as generateMetadata and Server Component rendering are typically direct children of that span. + const path = pathname ?? sanitizeRoutePath(parameterizedPath); + + const protocol = headersDict?.[HeaderKeys.FORWARDED_PROTO]; + const host = headersDict?.[HeaderKeys.FORWARDED_HOST] || headersDict?.[HeaderKeys.HOST]; + + if (!protocol || !host) { + return path; + } + + const fullUrl = `${protocol}://${host}${path}`; + + const urlObject = parseStringToURLObject(fullUrl); + if (!urlObject) { + return path; + } + + return getSanitizedUrlStringFromUrlObject(urlObject); +} + +/** + * Returns a sanitized URL string from the referer header if it exists and is valid. + * + * @param headersDict - Optional headers containing the referer + * @returns A sanitized URL string or undefined if referer is missing/invalid + */ +export function extractSanitizedUrlFromRefererHeader(headersDict?: HeadersDict): string | undefined { + const referer = headersDict?.[HeaderKeys.REFERER]; + if (!referer) { + return undefined; + } + + try { + const refererUrl = new URL(referer); + return getSanitizedUrlStringFromUrlObject(refererUrl); + } catch (error) { + return undefined; + } +} + +/** + * Returns a sanitized URL string using the referer header if available, + * otherwise constructs the URL from the component route, params, and headers. + * + * @param componentRoute - The route template to construct the URL from + * @param params - Optional route parameters to replace in the template + * @param headersDict - Optional headers containing protocol, host, and referer + * @param pathname - Optional pathname coming from root span "http.target" + * @returns A sanitized URL string + */ +export function getSanitizedRequestUrl( + componentRoute: string, + params?: ComponentRouteParams, + headersDict?: HeadersDict, + pathname?: string, +): string { + const refererUrl = extractSanitizedUrlFromRefererHeader(headersDict); + if (refererUrl) { + return refererUrl; + } + + return buildUrlFromComponentRoute(componentRoute, params, headersDict, pathname); +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 801c0e9b0dab..79af67475b06 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -13,6 +13,7 @@ import { setCapturedScopesOnSpan, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + spanToJSON, startSpanManual, winterCGHeadersToDict, withIsolationScope, @@ -22,7 +23,7 @@ import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; - +import { getSanitizedRequestUrl } from './utils/urls'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. */ @@ -44,14 +45,23 @@ export function wrapGenerationFunctionWithSentry a } const isolationScope = commonObjectToIsolationScope(headers); + let pathname = undefined as string | undefined; const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); + + const spanData = spanToJSON(rootSpan); + + if (spanData.data && 'http.target' in spanData.data) { + pathname = spanData.data['http.target'] as string; + } } + const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; + let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; @@ -61,8 +71,6 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); @@ -70,6 +78,12 @@ export function wrapGenerationFunctionWithSentry a isolationScope.setSDKProcessingMetadata({ normalizedRequest: { headers: headersDict, + url: getSanitizedRequestUrl( + componentRoute, + data?.params as Record | undefined, + headersDict, + pathname, + ), } satisfies RequestEventData, }); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 7319ddee9837..16f6728deda1 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -3,6 +3,7 @@ import { captureException, getActiveSpan, getCapturedScopesOnSpan, + getClient, getRootSpan, handleCallbackErrors, propagationContextFromHeaders, @@ -12,6 +13,7 @@ import { setCapturedScopesOnSpan, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + spanToJSON, startSpanManual, vercelWaitUntil, winterCGHeadersToDict, @@ -23,6 +25,7 @@ import type { ServerComponentContext } from '../common/types'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; +import { getSanitizedRequestUrl } from './utils/urls'; /** * Wraps an `app` directory server component with Sentry error instrumentation. @@ -41,18 +44,36 @@ export function wrapServerComponentWithSentry any> const requestTraceId = getActiveSpan()?.spanContext().traceId; const isolationScope = commonObjectToIsolationScope(context.headers); + let pathname = undefined as string | undefined; const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); + + const spanData = spanToJSON(rootSpan); + + if (spanData.data && 'http.target' in spanData.data) { + pathname = spanData.data['http.target']?.toString(); + } } const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; + let params: Record | undefined = undefined; + + if (getClient()?.getOptions().sendDefaultPii) { + const props: unknown = args[0]; + params = + props && typeof props === 'object' && 'params' in props + ? (props.params as Record) + : undefined; + } + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { headers: headersDict, + url: getSanitizedRequestUrl(componentRoute, params, headersDict, pathname), } satisfies RequestEventData, }); diff --git a/packages/nextjs/test/utils/urls.test.ts b/packages/nextjs/test/utils/urls.test.ts new file mode 100644 index 000000000000..5b122ef915ec --- /dev/null +++ b/packages/nextjs/test/utils/urls.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { + buildUrlFromComponentRoute, + extractSanitizedUrlFromRefererHeader, + getSanitizedRequestUrl, + sanitizeRoutePath, + substituteRouteParams, +} from '../../src/common/utils/urls'; + +describe('URL Utilities', () => { + describe('buildUrlFromComponentRoute', () => { + const mockHeaders = { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'example.com', + host: 'example.com', + }; + + it('should build URL with protocol and host', () => { + const result = buildUrlFromComponentRoute('/test', undefined, mockHeaders); + expect(result).toBe('https://example.com/test'); + }); + + it('should handle route parameters', () => { + const result = buildUrlFromComponentRoute( + '/users/[id]/posts/[postId]', + { id: '123', postId: '456' }, + mockHeaders, + ); + expect(result).toBe('https://example.com/users/123/posts/456'); + }); + + it('should handle multiple instances of the same parameter', () => { + const result = buildUrlFromComponentRoute('/users/[id]/[id]/profile', { id: '123' }, mockHeaders); + expect(result).toBe('https://example.com/users/123/123/profile'); + }); + + it('should handle special characters in parameters', () => { + const result = buildUrlFromComponentRoute('/search/[query]', { query: 'hello world' }, mockHeaders); + expect(result).toBe('https://example.com/search/hello%20world'); + }); + + it('should handle route groups', () => { + const result = buildUrlFromComponentRoute('/(auth)/login', undefined, mockHeaders); + expect(result).toBe('https://example.com/login'); + }); + + it('should normalize multiple slashes', () => { + const result = buildUrlFromComponentRoute('//users///profile', undefined, mockHeaders); + expect(result).toBe('https://example.com/users/profile'); + }); + + it('should handle trailing slashes', () => { + const result = buildUrlFromComponentRoute('/users/', undefined, mockHeaders); + expect(result).toBe('https://example.com/users'); + }); + + it('should handle root path', () => { + const result = buildUrlFromComponentRoute('', undefined, mockHeaders); + expect(result).toBe('https://example.com/'); + }); + + it('should use pathname if provided', () => { + const result = buildUrlFromComponentRoute('/original', undefined, mockHeaders, '/override'); + expect(result).toBe('https://example.com/override'); + }); + + it('should return path only if protocol is missing', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { host: 'example.com' }); + expect(result).toBe('/test'); + }); + + it('should return path only if host is missing', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { 'x-forwarded-proto': 'https' }); + expect(result).toBe('/test'); + }); + + it('should handle invalid URL construction', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { + 'x-forwarded-proto': 'invalid://', + host: 'example.com', + }); + expect(result).toBe('/test'); + }); + }); + + describe('extractSanitizedUrlFromRefererHeader', () => { + it('should return undefined if referer is missing', () => { + const result = extractSanitizedUrlFromRefererHeader({}); + expect(result).toBeUndefined(); + }); + + it('should return undefined if referer is invalid', () => { + const result = extractSanitizedUrlFromRefererHeader({ referer: 'invalid-url' }); + expect(result).toBeUndefined(); + }); + + it('should handle referer with special characters', () => { + const headers = { referer: 'https://example.com/path with spaces/ümlaut' }; + const result = extractSanitizedUrlFromRefererHeader(headers); + expect(result).toBe('https://example.com/path%20with%20spaces/%C3%BCmlaut'); + }); + }); + + describe('getSanitizedRequestUrl', () => { + const mockHeaders = { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'example.com', + host: 'example.com', + }; + + it('should use referer URL if available and valid', () => { + const headers = { + ...mockHeaders, + referer: 'https://example.com/referer-page', + }; + const result = getSanitizedRequestUrl('/original', undefined, headers); + expect(result).toBe('https://example.com/referer-page'); + }); + + it('should fall back to building URL if referer is invalid', () => { + const headers = { + ...mockHeaders, + referer: 'invalid-url', + }; + const result = getSanitizedRequestUrl('/fallback', undefined, headers); + expect(result).toBe('https://example.com/fallback'); + }); + + it('should fall back to building URL if referer is missing', () => { + const result = getSanitizedRequestUrl('/fallback', undefined, mockHeaders); + expect(result).toBe('https://example.com/fallback'); + }); + + it('should handle route parameters in fallback URL', () => { + const result = getSanitizedRequestUrl('/users/[id]', { id: '123' }, mockHeaders); + expect(result).toBe('https://example.com/users/123'); + }); + + it('should handle pathname override in fallback URL', () => { + const result = getSanitizedRequestUrl('/original', undefined, mockHeaders, '/override'); + expect(result).toBe('https://example.com/override'); + }); + + it('should handle empty headers', () => { + const result = getSanitizedRequestUrl('/test', undefined, {}); + expect(result).toBe('/test'); + }); + + it('should handle undefined headers', () => { + const result = getSanitizedRequestUrl('/test', undefined, undefined); + expect(result).toBe('/test'); + }); + }); + + describe('sanitizeRoutePath', () => { + it('should handle root path', () => { + const result = sanitizeRoutePath(''); + expect(result).toBe('/'); + }); + + it('should handle multiple slashes', () => { + const result = sanitizeRoutePath('////foo///bar'); + expect(result).toBe('/foo/bar'); + }); + + it('should handle route groups', () => { + const result = sanitizeRoutePath('/products/(auth)/details'); + expect(result).toBe('/products/details'); + }); + }); + + describe('substituteRouteParams', () => { + it('should handle route parameters', () => { + const result = substituteRouteParams('/users/[id]', { id: '123' }); + expect(result).toBe('/users/123'); + }); + + it('should handle multiple instances of the same parameter', () => { + const result = substituteRouteParams('/users/[id]/[id]/profile', { id: '123' }); + expect(result).toBe('/users/123/123/profile'); + }); + + it('should handle special characters in parameters', () => { + const result = substituteRouteParams('/search/[query]', { query: 'hello world' }); + expect(result).toBe('/search/hello%20world'); + }); + + it('should handle undefined parameters', () => { + const result = substituteRouteParams('/users/[id]', undefined); + expect(result).toBe('/users/[id]'); + }); + }); +});