diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 828a4f0a541a..37763c0e0607 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -12,6 +12,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -65,6 +66,9 @@ export function init(options: BrowserOptions): Client | undefined { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, + // Both route manifests are injected at build time, so route parameterization works from `init` on, + // including for the pageload span and with tracing disabled. + routeProvider: createNextRouteProvider(), ...options, } satisfies BrowserOptions; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index eb3edbf3eced..cd0d0980ade8 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -13,8 +13,10 @@ import { startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, + resolveCurrentRoute, + resolveRoute, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { stripTrailingSlash } from './parameterization'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -24,14 +26,6 @@ import { } from '@sentry/conventions/attributes'; import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; -/** - * Strips trailing slash from a pathname, unless it's the root path. - * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. - */ -function stripTrailingSlash(pathname: string): string { - return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; -} - function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ [URL_PATH]: urlPath, @@ -103,7 +97,7 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); startBrowserTracingPageLoadSpan(client, { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), @@ -158,7 +152,7 @@ export function appRouterInstrumentNavigation(client: Client): void { const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); - const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const parameterizedPathname = resolveRoute(normalizedHref, client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? @@ -198,7 +192,7 @@ export function appRouterInstrumentNavigation(client: Client): void { WINDOW.addEventListener('popstate', () => { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); @@ -306,7 +300,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe const normalizedHref = basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href; const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); - const parameterizedPathname = maybeParameterizeRoute(transactionName); + const parameterizedPathname = resolveRoute(transactionName, client); currentNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, diff --git a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts index b94eadc5c134..44d842b6193a 100644 --- a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts @@ -5,10 +5,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } from '@sentry/react'; +import { getAbsoluteUrl, startBrowserTracingNavigationSpan } from '@sentry/react'; import RouterImport from 'next/router'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION } from '@sentry/conventions/op'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; // next/router v10 is CJS // @@ -17,8 +18,6 @@ const Router: typeof RouterImport = RouterImport.events ? RouterImport : (RouterImport as unknown as { default: typeof RouterImport }).default; -const globalObject = WINDOW; - /** * Instruments the Next.js pages router for navigation. * Only supported for client side routing. Works for Next >= 10. @@ -59,58 +58,3 @@ export function pagesRouterInstrumentNavigation(client: Client): void { ); }); } - -function getNextRouteFromPathname(pathname: string): string | undefined { - const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; - - // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here - if (!pageRoutes) { - return; - } - - return pageRoutes.find(route => { - const routeRegExp = convertNextRouteToRegExp(route); - return pathname.match(routeRegExp); - }); -} - -/** - * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). - * - * In general this involves replacing any instances of square brackets in a route with a wildcard: - * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ - * - * Some additional edgecases need to be considered: - * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or - * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". - * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). - * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). - * - * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` - */ -function convertNextRouteToRegExp(route: string): RegExp { - // We can assume a route is at least "/". - const routeParts = route.split('/'); - - let optionalCatchallWildcardRegex = ''; - if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { - // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing - // slash that would come before it if we didn't pop it. - routeParts.pop(); - optionalCatchallWildcardRegex = '(?:/(.+?))?'; - } - - const rejoinedRouteParts = routeParts - .map( - routePart => - routePart - .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard - .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards - ) - .join('/'); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input - return new RegExp( - `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end - ); -} diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 8cbf116334e4..7a4ebaad3877 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -122,3 +122,63 @@ export function pagesRouterInstrumentPageLoad(client: Client): void { { sentryTrace, baggage }, ); } + +/** + * Matches a pathname against the Pages Router build manifest, e.g. `/users/1` -> `/users/[id]`. + * + * Expects a pathname without `basePath`, which is what Next reports internally. + */ +export function getNextRouteFromPathname(pathname: string): string | undefined { + const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; + + // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here + if (!pageRoutes) { + return; + } + + return pageRoutes.find(route => { + const routeRegExp = convertNextRouteToRegExp(route); + return pathname.match(routeRegExp); + }); +} + +/** + * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). + * + * In general this involves replacing any instances of square brackets in a route with a wildcard: + * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ + * + * Some additional edgecases need to be considered: + * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or + * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". + * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). + * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). + * + * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` + */ +function convertNextRouteToRegExp(route: string): RegExp { + // We can assume a route is at least "/". + const routeParts = route.split('/'); + + let optionalCatchallWildcardRegex = ''; + if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { + // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing + // slash that would come before it if we didn't pop it. + routeParts.pop(); + optionalCatchallWildcardRegex = '(?:/(.+?))?'; + } + + const rejoinedRouteParts = routeParts + .map( + routePart => + routePart + .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard + .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards + ) + .join('/'); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input + return new RegExp( + `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end + ); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index da25c1beb840..567bddacdc75 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -12,6 +12,30 @@ let cachedManifestString: string | undefined = undefined; const compiledRegexCache: Map = new Map(); const routeResultCache: Map = new Map(); +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + +/** + * Strips trailing slash from a pathname, unless it's the root path. + * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. + */ +export function stripTrailingSlash(pathname: string): string { + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +/** + * Removes the configured `basePath` from a pathname. + * + * App Router routes are generated with `basePath` baked in, but Next strips it internally for the + * Pages Router, so `__BUILD_MANIFEST.sortedPages` holds routes without it. + */ +export function stripBasePath(pathname: string): string { + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + + return basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) || '/' : pathname; +} + // Specificity ranks for a single route segment, from most to least specific. `END` is the rank of // the position just past the last segment of a route, so that a route which stops is compared // against whatever the longer route continues with. diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts new file mode 100644 index 000000000000..d6ef74b8698c --- /dev/null +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -0,0 +1,26 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; + +/** + * Resolves a URL against whichever router manifest the app ships. + * + * App Router routes are generated with `basePath` baked in, which is what `location.pathname` gives + * us; Next strips it internally for the Pages Router, so the fallback strips it too. + */ +function resolveNextRoute(url: { pathname: string }): string | undefined { + const pathname = stripTrailingSlash(url.pathname); + + return maybeParameterizeRoute(pathname) ?? getNextRouteFromPathname(stripBasePath(pathname)); +} + +/** + * A route provider backed by the route manifests Next.js injects at build time. + * + * Both manifests are on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createNextRouteProvider(): RouteProvider { + return createUrlRouteProvider(resolveNextRoute); +} diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index e54e9867cc89..7c7701be40f6 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -11,11 +11,13 @@ import '@sentry/core'; import '@sentry/react'; import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; +import type * as RouteProvider from '../../src/client/routing/routeProvider'; import type { RouteManifest } from '../../src/config/manifest/types'; type Core = typeof SentryCore; type React = typeof SentryReact; type Instrumentation = typeof AppRouterInstrumentation; +type RouteProviderModule = typeof RouteProvider; interface NextRouter { back: () => void; @@ -61,6 +63,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ const core: Core = await import('@sentry/core'); const react: React = await import('@sentry/react'); const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation'); + const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider'); const client = new react.BrowserClient({ dsn: 'http://examplePublicKey@localhost/0', @@ -68,6 +71,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ stackParser: () => [], tracesSampleRate: 1, traceLifecycle, + routeProvider: routeProvider.createNextRouteProvider(), integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], }); core.setCurrentClient(client); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..90721c503867 --- /dev/null +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -0,0 +1,97 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, setCurrentClient, resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/react'; +import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; + +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest?: string; + _sentryBasePath?: string; + __BUILD_MANIFEST?: { sortedPages?: string[] }; +}; + +let originalDocument: unknown; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], + isrRoutes: [], +}); + +function makeClient(): BrowserClient { + // Deliberately no integrations at all, so nothing tracing-related can be supplying the route. + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('createNextRouteProvider', () => { + beforeEach(() => { + globalWithManifest._sentryRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `getLocationHref()` reads `document.location.href`; the listener stubs are only here so + // `client.init()` does not trip over the stand-in. + (GLOBAL_OBJ as { document?: unknown }).document = { + location: { href: 'https://example.com/users/42' }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + }); + + afterEach(() => { + delete globalWithManifest._sentryRouteManifest; + delete globalWithManifest._sentryBasePath; + delete globalWithManifest.__BUILD_MANIFEST; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('resolves the current route without a tracing integration', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(client.getIntegrationByName('BrowserTracing')).toBeUndefined(); + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/nope/deep', client)).toBeUndefined(); + }); + + describe('Pages Router', () => { + beforeEach(() => { + globalWithManifest.__BUILD_MANIFEST = { sortedPages: ['/', '/_app', '/_error', '/posts/[slug]'] }; + }); + + it('falls back to the Pages Router manifest when the App Router manifest has no match', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/posts/hello', client)).toBe('/posts/[slug]'); + }); + + it('strips `basePath` before matching, since Pages Router routes are generated without it', () => { + globalWithManifest._sentryBasePath = '/docs'; + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/docs/posts/hello', client)).toBe('/posts/[slug]'); + expect(resolveRoute('https://example.com/docs', client)).toBe('/'); + }); + }); +}); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index a4cf4869102f..f9367080f699 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, SentryNonRecordingSpan } from '@sentry/core'; +import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -188,6 +188,36 @@ describe('Client init()', () => { delete globalThis.__SENTRY_TRACING__; }); + it('names the pageload span after the parameterized route', () => { + const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; + globalWithManifest._sentryRouteManifest = JSON.stringify({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }); + + init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); + + expect(spanToJSON(SentryReact.getActiveSpan()!)).toMatchObject({ + name: '/', + attributes: { + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/', + }, + }); + + delete globalWithManifest._sentryRouteManifest; + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + + init({ dsn: TEST_DSN, routeProvider }); + + expect(reactInit).toHaveBeenCalledWith(expect.objectContaining({ routeProvider })); + }); + it("doesn't run Next.js router instrumentation for bot user agents", () => { Object.defineProperty(WINDOW, 'navigator', { value: {