From 27392bb7109869f64ff8dacf7c4fc59174dc0463 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 6 Jun 2025 10:42:24 +0200 Subject: [PATCH 1/2] feat(nextjs): Add URL to tags of server component and layout issues --- packages/nextjs/src/common/utils/urls.ts | 129 +++++++++++++++ .../wrapGenerationFunctionWithSentry.ts | 17 +- .../common/wrapServerComponentWithSentry.ts | 18 +++ packages/nextjs/test/utils/urls.test.ts | 150 ++++++++++++++++++ 4 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 packages/nextjs/src/common/utils/urls.ts create mode 100644 packages/nextjs/test/utils/urls.test.ts diff --git a/packages/nextjs/src/common/utils/urls.ts b/packages/nextjs/src/common/utils/urls.ts new file mode 100644 index 000000000000..f079dddf941b --- /dev/null +++ b/packages/nextjs/src/common/utils/urls.ts @@ -0,0 +1,129 @@ +import { parseStringToURLObject, getSanitizedUrlStringFromUrlObject } from '@sentry/core'; + +/** + * Type definition for component route parameters + */ +type ComponentRouteParams = Record | undefined; + +/** + * Type definition for headers dictionary + */ +type HeadersDict = Record | undefined; + + +const HEADER_KEYS = { + 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 + */ +function substituteRouteParams(path: string, params?: ComponentRouteParams): string { + if (!params || typeof params !== 'object') return path; + + for (const [key, value] of Object.entries(params)) { + const regex = new RegExp(`\\[${key}\\]`, 'g'); + path = path.replace(regex, encodeURIComponent(value)); + } + return path; +} + +/** + * Normalizes a path by removing route groups and multiple slashes + * @param path - The path to normalize + * @returns The normalized path + */ +function sanitizeRoutePath(path: string): string { + return path + .replace(/\([^)]+\)/g, '') // Remove route groups + .replace(/\/{2,}/g, '/') // Normalize multiple slashes + .replace(/\/$/, '') // Remove trailing slash + || '/'; // Ensure root path is '/' +} + +/** + * 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 parameterisedPath = substituteRouteParams(componentRoute, params); + // pathname has precedence over the parameterised path if it exists + // spans like generateMetadata and Server Component rendering, are normally direct children of the root http.server span + const path = pathname ?? sanitizeRoutePath(parameterisedPath); + + const protocol = headersDict?.[HEADER_KEYS.FORWARDED_PROTO]; + const host = headersDict?.[HEADER_KEYS.FORWARDED_HOST] || headersDict?.[HEADER_KEYS.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?.[HEADER_KEYS.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..34733dcca9e3 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,25 +45,32 @@ 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]; const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; const searchParams = props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; - data = { params, searchParams }; + data = { params, searchParams } as Record; } - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); @@ -70,6 +78,7 @@ 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..e8e1211e58b9 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,33 @@ 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..8bb287553364 --- /dev/null +++ b/packages/nextjs/test/utils/urls.test.ts @@ -0,0 +1,150 @@ + +import { describe, expect, it } from 'vitest'; +import { + buildUrlFromComponentRoute, + extractSanitizedUrlFromRefererHeader, + getSanitizedRequestUrl, +} 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'); + }); + }); +}); + From 493189918e4a5a279a0e8e4a2801395b464a7c2b Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 6 Jun 2025 10:59:59 +0200 Subject: [PATCH 2/2] Fix linter, some alerts --- packages/nextjs/src/common/utils/urls.ts | 36 +++++++++---------- .../wrapGenerationFunctionWithSentry.ts | 7 +++- .../common/wrapServerComponentWithSentry.ts | 7 ++-- packages/nextjs/test/utils/urls.test.ts | 8 +++-- 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/nextjs/src/common/utils/urls.ts b/packages/nextjs/src/common/utils/urls.ts index f079dddf941b..80a0b774fe5a 100644 --- a/packages/nextjs/src/common/utils/urls.ts +++ b/packages/nextjs/src/common/utils/urls.ts @@ -1,17 +1,9 @@ -import { parseStringToURLObject, getSanitizedUrlStringFromUrlObject } from '@sentry/core'; +import { getSanitizedUrlStringFromUrlObject, parseStringToURLObject } from '@sentry/core'; -/** - * Type definition for component route parameters - */ type ComponentRouteParams = Record | undefined; - -/** - * Type definition for headers dictionary - */ type HeadersDict = Record | undefined; - -const HEADER_KEYS = { +const HeaderKeys = { FORWARDED_PROTO: 'x-forwarded-proto', FORWARDED_HOST: 'x-forwarded-host', HOST: 'host', @@ -27,11 +19,14 @@ const HEADER_KEYS = { 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)) { - const regex = new RegExp(`\\[${key}\\]`, 'g'); - path = path.replace(regex, encodeURIComponent(value)); + const paramPattern = /\[([^\]]+)\]/g; + resultPath = resultPath.replace(paramPattern, (match, paramName) => { + return paramName === key ? encodeURIComponent(value) : match; + }); } - return path; + return resultPath; } /** @@ -40,11 +35,12 @@ function substituteRouteParams(path: string, params?: ComponentRouteParams): str * @returns The normalized path */ function sanitizeRoutePath(path: string): string { - return path + const normalizedPath = path .replace(/\([^)]+\)/g, '') // Remove route groups - .replace(/\/{2,}/g, '/') // Normalize multiple slashes - .replace(/\/$/, '') // Remove trailing slash - || '/'; // Ensure root path is '/' + .replace(/\/{2,}/g, '/') // Normalize multiple slashes + .replace(/\/$/, ''); // Remove trailing slash + + return normalizedPath || '/'; // Ensure root path is '/' } /** @@ -67,8 +63,8 @@ export function buildUrlFromComponentRoute( // spans like generateMetadata and Server Component rendering, are normally direct children of the root http.server span const path = pathname ?? sanitizeRoutePath(parameterisedPath); - const protocol = headersDict?.[HEADER_KEYS.FORWARDED_PROTO]; - const host = headersDict?.[HEADER_KEYS.FORWARDED_HOST] || headersDict?.[HEADER_KEYS.HOST]; + const protocol = headersDict?.[HeaderKeys.FORWARDED_PROTO]; + const host = headersDict?.[HeaderKeys.FORWARDED_HOST] || headersDict?.[HeaderKeys.HOST]; if (!protocol || !host) { return path; @@ -91,7 +87,7 @@ export function buildUrlFromComponentRoute( * @returns A sanitized URL string or undefined if referer is missing/invalid */ export function extractSanitizedUrlFromRefererHeader(headersDict?: HeadersDict): string | undefined { - const referer = headersDict?.[HEADER_KEYS.REFERER]; + const referer = headersDict?.[HeaderKeys.REFERER]; if (!referer) { return undefined; } diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 34733dcca9e3..02a2068ecc3b 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -78,7 +78,12 @@ export function wrapGenerationFunctionWithSentry a isolationScope.setSDKProcessingMetadata({ normalizedRequest: { headers: headersDict, - url: getSanitizedRequestUrl(componentRoute, data?.params as Record | undefined, headersDict, pathname), + 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 e8e1211e58b9..16f6728deda1 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -54,7 +54,7 @@ export function wrapServerComponentWithSentry any> const spanData = spanToJSON(rootSpan); if (spanData.data && 'http.target' in spanData.data) { - pathname = spanData.data['http.target']?.toString() + pathname = spanData.data['http.target']?.toString(); } } @@ -64,7 +64,10 @@ export function wrapServerComponentWithSentry any> if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; - params = props && typeof props === 'object' && 'params' in props ? (props.params as Record) : undefined; + params = + props && typeof props === 'object' && 'params' in props + ? (props.params as Record) + : undefined; } isolationScope.setSDKProcessingMetadata({ diff --git a/packages/nextjs/test/utils/urls.test.ts b/packages/nextjs/test/utils/urls.test.ts index 8bb287553364..8597512c3d58 100644 --- a/packages/nextjs/test/utils/urls.test.ts +++ b/packages/nextjs/test/utils/urls.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from 'vitest'; import { buildUrlFromComponentRoute, @@ -20,7 +19,11 @@ describe('URL Utilities', () => { }); it('should handle route parameters', () => { - const result = buildUrlFromComponentRoute('/users/[id]/posts/[postId]', { id: '123', postId: '456' }, mockHeaders); + const result = buildUrlFromComponentRoute( + '/users/[id]/posts/[postId]', + { id: '123', postId: '456' }, + mockHeaders, + ); expect(result).toBe('https://example.com/users/123/posts/456'); }); @@ -147,4 +150,3 @@ describe('URL Utilities', () => { }); }); }); -