From fb24bbdcf74bdf6f131ed3c918912ec7f7da5974 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 outside the tracing integration 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. Registered from `init()` rather than `browserTracingIntegration`, because both manifests are on the global object before `Sentry.init` runs. Route parameterization no longer depends on tracing being enabled, so `bfcacheMetrics` resolves a parameterized route with `browserTracingIntegration` absent. The two manifests 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. --- packages/nextjs/src/client/index.ts | 15 +++- .../appRouterRoutingInstrumentation.ts | 20 ++---- .../pagesRouterRoutingInstrumentation.ts | 7 +- .../src/client/routing/parameterization.ts | 24 +++++++ .../src/client/routing/routeProvider.ts | 26 +++++++ .../nextjs/test/client/routeProvider.test.ts | 69 +++++++++++++++++++ 6 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 packages/nextjs/src/client/routing/routeProvider.ts create mode 100644 packages/nextjs/test/client/routeProvider.test.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 5c5d3ffc2c85..b986cf40a202 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -2,7 +2,14 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import { + addEventProcessor, + applySdkMetadata, + consoleSandbox, + getGlobalScope, + GLOBAL_OBJ, + setRouteProvider, +} from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -13,6 +20,7 @@ import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -84,6 +92,11 @@ export function init(options: BrowserOptions): Client | undefined { const client = reactInit(opts); + // Registered here rather than from `browserTracingIntegration` so route parameterization does not + // depend on tracing: the route manifests are injected at build time, so anything that needs a route + // name (bfcache metrics, web vitals) can resolve one even with tracing disabled. + setRouteProvider(createNextRouteProvider(), client); + const filterNextRedirectError: EventProcessor = (event, hint) => isRedirectNavigationError(hint?.originalException) || event.exception?.values?.[0]?.value === 'NEXT_REDIRECT' ? null diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index aff86d2c2e37..6b51aaa032ae 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -4,6 +4,8 @@ import { hasSpanStreamingEnabled, NAVIGATION_SPAN_NAME_FALLBACK, PAGELOAD_SPAN_NAME_FALLBACK, + resolveCurrentRoute, + resolveRoute, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, filterCollectedUrl, } from '@sentry/core'; @@ -13,7 +15,7 @@ import { WINDOW, getAbsoluteUrl, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { stripTrailingSlash } from './parameterization'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -23,14 +25,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, @@ -65,7 +59,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), @@ -120,7 +114,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 ?? @@ -160,7 +154,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); @@ -275,7 +269,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe transactionAttributes['navigation.type'] = 'router.forward'; } - const parameterizedPathname = maybeParameterizeRoute(transactionName); + const parameterizedPathname = resolveRoute(transactionName, client); const navigationUrl = routerFunctionName === 'back' || routerFunctionName === 'forward' diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 1aee7a2e0c58..77eda88b42e6 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -179,7 +179,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 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..0ee05801c5b7 --- /dev/null +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -0,0 +1,26 @@ +import type { RouteProvider } from '@sentry/core'; +import { createUrlRouteProvider } from '@sentry/core'; +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: URL): 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/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..7c3ade8a3d9f --- /dev/null +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -0,0 +1,69 @@ +import { GLOBAL_OBJ, resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, setCurrentClient } from '@sentry/react'; +import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; + +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: 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; + (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(); + }); +});