From 1eb7910555f3249934c8f30d79b29bc0cd8fb2a8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 24 Aug 2026 15:38:24 -0400 Subject: [PATCH] feat(nextjs): Register a route provider for the App and Pages routers Both routers already ship a pure matcher: the App Router has the build-time route manifest behind `maybeParameterizeRoute`, and the Pages Router matches against `__BUILD_MANIFEST.sortedPages`. Neither was reachable from anywhere except the pageload and navigation instrumentation. The two want the pathname differently, since App Router routes are generated with `basePath` baked in while Next strips it internally for the Pages Router, so the provider normalizes per manifest. --- .../src/client/browserTracingIntegration.ts | 23 +++++++++++ .../appRouterRoutingInstrumentation.ts | 10 +---- .../pagesRouterRoutingInstrumentation.ts | 7 +++- .../src/client/routing/parameterization.ts | 40 +++++++++++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts index cd957d1d62b5..c5cf034b474f 100644 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -1,7 +1,22 @@ import type { Integration } from '@sentry/core'; +import { createUrlRouteProvider, setRouteProvider } from '@sentry/core/browser'; import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react'; +import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash, withBasePath } from './routing/parameterization'; +import { getNextRouteFromPathname } from './routing/pagesRouterRoutingInstrumentation'; import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; +/** + * Resolves a URL against whichever router manifest the app ships. + * + * The two want the pathname differently: App Router routes are generated with `basePath` baked in, + * while Next strips it internally for the Pages Router. + */ +function resolveNextRoute(url: URL): string | undefined { + const pathname = stripTrailingSlash(url.pathname); + + return maybeParameterizeRoute(withBasePath(pathname)) ?? getNextRouteFromPathname(stripBasePath(pathname)); +} + /** * A custom browser tracing integration for Next.js. */ @@ -28,6 +43,14 @@ export function browserTracingIntegration( return { ...browserTracingIntegrationInstance, + setup(client) { + // Registered here rather than in `afterAllSetup` so it is in place before the pageload span is + // named. The build-time route manifest is already on the global object at this point, so nothing + // has to wait for the router itself. + setRouteProvider(createUrlRouteProvider(resolveNextRoute), client); + + browserTracingIntegrationInstance.setup?.(client); + }, afterAllSetup(client) { if (isBotUserAgent()) { return; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 8ab4865e4e06..d40432a5c568 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -14,17 +14,9 @@ import { WINDOW, getAbsoluteUrl, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { maybeParameterizeRoute, stripTrailingSlash } from './parameterization'; import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; -/** - * 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, diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 824bf2dfa9c0..f890119593b9 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -178,7 +178,12 @@ export function pagesRouterInstrumentNavigation(client: Client): void { }); } -function getNextRouteFromPathname(pathname: string): string | undefined { +/** + * 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 diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index a45f0faab8fd..60d9b4564ae0 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -12,6 +12,46 @@ 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; +} + +function getBasePath(): string | undefined { + return process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; +} + +/** + * Prefixes a pathname with the configured `basePath` when it is missing. + * + * The App Router manifest is generated with `basePath` baked into every route, so a pathname that + * lacks it matches nothing. + */ +export function withBasePath(pathname: string): string { + const basePath = getBasePath(); + + return basePath && !pathname.startsWith(basePath) ? `${basePath}${pathname}` : pathname; +} + +/** + * Removes the configured `basePath` from a pathname. + * + * The opposite of {@link withBasePath}, because Next strips `basePath` internally for the Pages + * Router: `__BUILD_MANIFEST.sortedPages` holds routes without it. + */ +export function stripBasePath(pathname: string): string { + const basePath = getBasePath(); + + return basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) || '/' : pathname; +} + /** * Calculate the specificity score for a route path. * Lower scores indicate more specific routes.