Skip to content
Draft
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
15 changes: 14 additions & 1 deletion packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
hasSpanStreamingEnabled,
NAVIGATION_SPAN_NAME_FALLBACK,
PAGELOAD_SPAN_NAME_FALLBACK,
resolveCurrentRoute,
resolveRoute,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
filterCollectedUrl,
} from '@sentry/core';
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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/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);
}
69 changes: 69 additions & 0 deletions packages/nextjs/test/client/routeProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading