Skip to content

Commit 464c7d0

Browse files
committed
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.
1 parent 905570f commit 464c7d0

4 files changed

Lines changed: 70 additions & 10 deletions

File tree

‎packages/nextjs/src/client/browserTracingIntegration.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
import type { Integration } from '@sentry/core';
2+
import { createUrlRouteProvider, setRouteProvider } from '@sentry/core/browser';
23
import { browserTracingIntegration as originalBrowserTracingIntegration, isBotUserAgent } from '@sentry/react';
4+
import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash, withBasePath } from './routing/parameterization';
5+
import { getNextRouteFromPathname } from './routing/pagesRouterRoutingInstrumentation';
36
import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation';
47

8+
/**
9+
* Resolves a URL against whichever router manifest the app ships.
10+
*
11+
* The two want the pathname differently: App Router routes are generated with `basePath` baked in,
12+
* while Next strips it internally for the Pages Router.
13+
*/
14+
function resolveNextRoute(url: URL): string | undefined {
15+
const pathname = stripTrailingSlash(url.pathname);
16+
17+
return maybeParameterizeRoute(withBasePath(pathname)) ?? getNextRouteFromPathname(stripBasePath(pathname));
18+
}
19+
520
/**
621
* A custom browser tracing integration for Next.js.
722
*/
@@ -28,6 +43,14 @@ export function browserTracingIntegration(
2843

2944
return {
3045
...browserTracingIntegrationInstance,
46+
setup(client) {
47+
// Registered here rather than in `afterAllSetup` so it is in place before the pageload span is
48+
// named. The build-time route manifest is already on the global object at this point, so nothing
49+
// has to wait for the router itself.
50+
setRouteProvider(createUrlRouteProvider(resolveNextRoute), client);
51+
52+
browserTracingIntegrationInstance.setup?.(client);
53+
},
3154
afterAllSetup(client) {
3255
if (isBotUserAgent()) {
3356
return;

‎packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,9 @@ import {
1414
WINDOW,
1515
getAbsoluteUrl,
1616
} from '@sentry/react';
17-
import { maybeParameterizeRoute } from './parameterization';
17+
import { maybeParameterizeRoute, stripTrailingSlash } from './parameterization';
1818
import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes';
1919

20-
/**
21-
* Strips trailing slash from a pathname, unless it's the root path.
22-
* This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config.
23-
*/
24-
function stripTrailingSlash(pathname: string): string {
25-
return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
26-
}
27-
2820
function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void {
2921
span.setAttributes({
3022
[URL_PATH]: urlPath,

‎packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ export function pagesRouterInstrumentNavigation(client: Client): void {
178178
});
179179
}
180180

181-
function getNextRouteFromPathname(pathname: string): string | undefined {
181+
/**
182+
* Matches a pathname against the Pages Router build manifest, e.g. `/users/1` -> `/users/[id]`.
183+
*
184+
* Expects a pathname without `basePath`, which is what Next reports internally.
185+
*/
186+
export function getNextRouteFromPathname(pathname: string): string | undefined {
182187
const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages;
183188

184189
// Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here

‎packages/nextjs/src/client/routing/parameterization.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,46 @@ let cachedManifestString: string | undefined = undefined;
1212
const compiledRegexCache: Map<string, RegExp> = new Map();
1313
const routeResultCache: Map<string, string | undefined> = new Map();
1414

15+
const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
16+
_sentryBasePath: string | undefined;
17+
};
18+
19+
/**
20+
* Strips trailing slash from a pathname, unless it's the root path.
21+
* This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config.
22+
*/
23+
export function stripTrailingSlash(pathname: string): string {
24+
return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
25+
}
26+
27+
function getBasePath(): string | undefined {
28+
return process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
29+
}
30+
31+
/**
32+
* Prefixes a pathname with the configured `basePath` when it is missing.
33+
*
34+
* The App Router manifest is generated with `basePath` baked into every route, so a pathname that
35+
* lacks it matches nothing.
36+
*/
37+
export function withBasePath(pathname: string): string {
38+
const basePath = getBasePath();
39+
40+
return basePath && !pathname.startsWith(basePath) ? `${basePath}${pathname}` : pathname;
41+
}
42+
43+
/**
44+
* Removes the configured `basePath` from a pathname.
45+
*
46+
* The opposite of {@link withBasePath}, because Next strips `basePath` internally for the Pages
47+
* Router: `__BUILD_MANIFEST.sortedPages` holds routes without it.
48+
*/
49+
export function stripBasePath(pathname: string): string {
50+
const basePath = getBasePath();
51+
52+
return basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) || '/' : pathname;
53+
}
54+
1555
/**
1656
* Calculate the specificity score for a route path.
1757
* Lower scores indicate more specific routes.

0 commit comments

Comments
 (0)