Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(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 @@ -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,
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
);
}
24 changes: 24 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,30 @@ 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.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.
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);
}
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 @@ -61,13 +63,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
Loading
Loading