Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import { expect, test } from '@playwright/test';
import { findAbsolutePathImports } from '@sentry-internal/test-utils';
import * as fs from 'fs';
import * as path from 'path';
import { isDevMode } from './isDevMode';

test('emits no absolute-path imports into the server output', () => {
const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') });

expect(leaks).toEqual([]);
});

// This app has no `pages` directory, so `withSentryConfig` lets the bundler drop the Pages Router navigation
// instrumentation and its `next/router` import (~80 KB raw).
test('does not ship the Pages Router runtime in the App Router client bundle', () => {
test.skip(isDevMode, 'Only production builds are tree-shaken');

const buildManifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.next', 'build-manifest.json'), 'utf8'));
const rootMainFiles: string[] = buildManifest.rootMainFiles;
expect(rootMainFiles.length).toBeGreaterThan(0);

const appClientBundle = rootMainFiles
.map(file => fs.readFileSync(path.join(process.cwd(), '.next', file), 'utf8'))
.join('\n');

// Control: the Sentry client is in these files.
expect(appClientBundle).toContain('auto.pageload.nextjs.pages_router_instrumentation');

expect(appClientBundle).not.toContain('auto.navigation.nextjs.pages_router_instrumentation');
// Only Next.js' Pages Router runtime contains this event name.
expect(appClientBundle).not.toContain('beforeHistoryChange');
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Client } from '@sentry/core';
import { WINDOW } from '@sentry/react';
import { appRouterInstrumentNavigation, appRouterInstrumentPageLoad } from './appRouterRoutingInstrumentation';
import { pagesRouterInstrumentNavigation, pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation';
import { pagesRouterInstrumentNavigation } from './pagesRouterNavigationInstrumentation';
import { pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation';

/**
* Instruments the Next.js Client Router for page loads.
Expand All @@ -22,7 +23,10 @@ export function nextRouterInstrumentNavigation(client: Client): void {
const isAppRouter = !WINDOW.document.getElementById('__NEXT_DATA__');
if (isAppRouter) {
appRouterInstrumentNavigation(client);
} else {
} else if (process.env._sentryHasPagesRouter !== 'false') {
// `withSentryConfig` inlines `'false'` for App Router-only projects, so bundlers drop this module and its
// `next/router` import (the whole Pages Router runtime). Pageload stays: App Router builds still serve
// `404.html`/`500.html` through the Pages Router, and it does not need `next/router`.
pagesRouterInstrumentNavigation(client);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Client, TransactionSource } from '@sentry/core';
import {
hasSpanStreamingEnabled,
NAVIGATION_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
stripUrlQueryAndFragment,
} from '@sentry/core';
import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } 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';

// next/router v10 is CJS
//
// For ESM/CJS interoperability 'reasons', depending on how this file is loaded, Router might be on the default export
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.
*
* Leverages the SingletonRouter from the `next/router` to
* generate pageload/navigation transactions and parameterize
* transaction names.
*/
export function pagesRouterInstrumentNavigation(client: Client): void {
Router.events.on('routeChangeStart', (navigationTarget: string) => {
const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget);
const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget);

let newLocation: string;
let spanSource: TransactionSource;

if (matchedRoute) {
newLocation = matchedRoute;
spanSource = 'route';
} else {
newLocation = strippedNavigationTarget;
spanSource = 'url';
}

startBrowserTracingNavigationSpan(
client,
{
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
name: spanSource === 'route' || !hasSpanStreamingEnabled(client) ? newLocation : NAVIGATION_SPAN_NAME_FALLBACK,
attributes: {
[SENTRY_OP]: NAVIGATION,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.pages_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: spanSource,
...(spanSource === 'route' && { [URL_TEMPLATE]: newLocation }),
},
},
{ url: getAbsoluteUrl(navigationTarget) },
);
});
}

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
@@ -1,32 +1,17 @@
import type { Client, TransactionSource } from '@sentry/core';
import type { Client } from '@sentry/core';
import {
debug,
hasSpanStreamingEnabled,
NAVIGATION_SPAN_NAME_FALLBACK,
PAGELOAD_SPAN_NAME_FALLBACK,
parseBaggageHeader,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
stripUrlQueryAndFragment,
} from '@sentry/core';
import {
getAbsoluteUrl,
startBrowserTracingNavigationSpan,
startBrowserTracingPageLoadSpan,
WINDOW,
} from '@sentry/react';
import { startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react';
import type { NEXT_DATA } from 'next/dist/shared/lib/utils';
import RouterImport from 'next/router';
import type { ParsedUrlQuery } from 'querystring';
import { DEBUG_BUILD } from '../../common/debug-build';
import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes';
import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op';

// next/router v10 is CJS
//
// For ESM/CJS interoperability 'reasons', depending on how this file is loaded, Router might be on the default export
const Router: typeof RouterImport = RouterImport.events
? RouterImport
: (RouterImport as unknown as { default: typeof RouterImport }).default;
import { PAGELOAD } from '@sentry/conventions/op';

const globalObject = WINDOW;

Expand Down Expand Up @@ -137,99 +122,3 @@ export function pagesRouterInstrumentPageLoad(client: Client): void {
{ sentryTrace, baggage },
);
}

/**
* Instruments the Next.js pages router for navigation.
* Only supported for client side routing. Works for Next >= 10.
*
* Leverages the SingletonRouter from the `next/router` to
* generate pageload/navigation transactions and parameterize
* transaction names.
*/
export function pagesRouterInstrumentNavigation(client: Client): void {
Router.events.on('routeChangeStart', (navigationTarget: string) => {
const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget);
const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget);

let newLocation: string;
let spanSource: TransactionSource;

if (matchedRoute) {
newLocation = matchedRoute;
spanSource = 'route';
} else {
newLocation = strippedNavigationTarget;
spanSource = 'url';
}

startBrowserTracingNavigationSpan(
client,
{
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
name: spanSource === 'route' || !hasSpanStreamingEnabled(client) ? newLocation : NAVIGATION_SPAN_NAME_FALLBACK,
attributes: {
[SENTRY_OP]: NAVIGATION,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.pages_router_instrumentation',
[SENTRY_SEGMENT_NAME_SOURCE]: spanSource,
...(spanSource === 'route' && { [URL_TEMPLATE]: newLocation }),
},
},
{ url: getAbsoluteUrl(navigationTarget) },
);
});
}

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
);
}
8 changes: 8 additions & 0 deletions packages/nextjs/src/config/withSentryConfig/buildTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import type { NextConfigObject, SentryBuildOptions } from '../types';
import { hasOnlyAppRouterPages } from './pagesRouterDetection';

/**
* Adds Sentry-related build-time variables to `nextConfig.env`.
Expand All @@ -11,11 +12,13 @@ import type { NextConfigObject, SentryBuildOptions } from '../types';
* @param userNextConfig - The user's Next.js config object
* @param userSentryOptions - The Sentry build options passed to `withSentryConfig`
* @param releaseName - The resolved release name, if any
* @param projectDir - The Next.js project root
*/
export function setUpBuildTimeVariables(
userNextConfig: NextConfigObject,
userSentryOptions: SentryBuildOptions,
releaseName: string | undefined,
projectDir: string = process.cwd(),
): void {
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';
const basePath = userNextConfig.basePath ?? '';
Expand Down Expand Up @@ -66,6 +69,11 @@ export function setUpBuildTimeVariables(
buildTimeVariables._sentryRelease = releaseName;
}

// See `client/routing/nextRoutingInstrumentation.ts`.
if (hasOnlyAppRouterPages(projectDir)) {
buildTimeVariables._sentryHasPagesRouter = 'false';
}

if (typeof userNextConfig.env === 'object') {
userNextConfig.env = { ...buildTimeVariables, ...userNextConfig.env };
} else if (userNextConfig.env === undefined) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as fs from 'fs';
import * as path from 'path';

/**
* Whether the project has an `app` directory and no page files outside `pages/api`.
*
* A false positive would silently drop Pages Router navigation spans, so any file outside `pages/api` counts as a
* page (covers custom `pageExtensions` and `_app`/`_document`), and no `app` directory means `false`.
*/
export function hasOnlyAppRouterPages(projectDir: string): boolean {
const hasAppDir = ['app', path.join('src', 'app')].some(dir => isDirectory(path.join(projectDir, dir)));
if (!hasAppDir) {
return false;
}

return ['pages', path.join('src', 'pages')].every(dir => !containsNonApiPages(path.join(projectDir, dir)));
}

function containsNonApiPages(pagesDir: string): boolean {
if (!isDirectory(pagesDir)) {
return false;
}

return fs.readdirSync(pagesDir).some(entry => entry !== 'api');
}

function isDirectory(dir: string): boolean {
try {
return fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
Loading
Loading