Skip to content
4 changes: 4 additions & 0 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -158,7 +152,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
const normalizedHref = basePath && href.startsWith('/') && !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 ??
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -308,7 +302,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
? `${basePath}${href}`
: href;
const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
const parameterizedPathname = maybeParameterizeRoute(transactionName);
const parameterizedPathname = resolveRoute(transactionName, client);

currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(
client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -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.
Expand Down Expand Up @@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
26 changes: 26 additions & 0 deletions packages/nextjs/src/client/routing/parameterization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@ let cachedManifestString: string | undefined = undefined;
const compiledRegexCache: Map<string, RegExp> = new Map();
const routeResultCache: Map<string, string | undefined> = 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 === basePath || pathname.startsWith(`${basePath}/`))
? pathname.slice(basePath.length) || '/'
: pathname;
}
Comment thread
cursor[bot] marked this conversation as resolved.

// 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.
Expand Down
26 changes: 26 additions & 0 deletions packages/nextjs/src/client/routing/routeProvider.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,13 +71,15 @@ 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',
transport: () => core.createTransport({ recordDroppedEvent: () => undefined }, () => core.resolvedSyncPromise({})),
stackParser: () => [],
tracesSampleRate: 1,
traceLifecycle,
routeProvider: routeProvider.createNextRouteProvider(),
integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })],
});
core.setCurrentClient(client);
Expand Down
26 changes: 25 additions & 1 deletion packages/nextjs/test/client/parameterization.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { GLOBAL_OBJ } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { maybeParameterizeRoute } from '../../src/client/routing/parameterization';
import { maybeParameterizeRoute, stripBasePath } from '../../src/client/routing/parameterization';
import type { RouteManifest } from '../../src/config/manifest/types';

const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryRouteManifest: string | undefined;
_sentryBasePath?: string;
};

describe('maybeParameterizeRoute', () => {
Expand Down Expand Up @@ -1112,3 +1113,26 @@ describe('maybeParameterizeRoute', () => {
});
});
});

describe('stripBasePath', () => {
afterEach(() => {
delete globalWithInjectedManifest._sentryBasePath;
});

it('strips the base path from paths under it', () => {
globalWithInjectedManifest._sentryBasePath = '/docs';

expect(stripBasePath('/docs/posts/hello')).toBe('/posts/hello');
expect(stripBasePath('/docs')).toBe('/');
});

it('leaves paths that only share a prefix with the base path alone', () => {
globalWithInjectedManifest._sentryBasePath = '/docs';

expect(stripBasePath('/documentation')).toBe('/documentation');
});

it('leaves paths alone without a base path', () => {
expect(stripBasePath('/docs/posts/hello')).toBe('/docs/posts/hello');
});
});
Loading
Loading