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
71 changes: 55 additions & 16 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ export type SentryBuildOptions = {
* A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components.
*/
ignoredComponents?: string[];
};
}; // TODO(v11): remove this option

/**
* Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry Next.js SDK.
Expand All @@ -500,7 +500,7 @@ export type SentryBuildOptions = {
* Please note that this option is unstable and may change in a breaking way in any release.
* @deprecated Use `webpack.unstable_sentryWebpackPluginOptions` instead.
*/
unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions;
unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; // TODO(v11): remove this option

/**
* Include Next.js-internal code and code from dependencies when uploading source maps.
Expand All @@ -522,19 +522,19 @@ export type SentryBuildOptions = {
* Defaults to `true`.
* @deprecated Use `webpack.autoInstrumentServerFunctions` instead.
*/
autoInstrumentServerFunctions?: boolean;
autoInstrumentServerFunctions?: boolean; // TODO(v11): remove this option

/**
* Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`.
* @deprecated Use `webpack.autoInstrumentMiddleware` instead.
*/
autoInstrumentMiddleware?: boolean;
autoInstrumentMiddleware?: boolean; // TODO(v11): remove this option

/**
* Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`.
* @deprecated Use `webpack.autoInstrumentAppDirectory` instead.
*/
autoInstrumentAppDirectory?: boolean;
autoInstrumentAppDirectory?: boolean; // TODO(v11): remove this option

/**
* Exclude certain serverside API routes or pages from being instrumented with Sentry during build-time. This option
Expand Down Expand Up @@ -567,7 +567,7 @@ export type SentryBuildOptions = {
*
* @deprecated Use `webpack.treeshake.removeDebugLogging` instead.
*/
disableLogger?: boolean;
disableLogger?: boolean; // TODO(v11): remove this option

/**
* Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`.
Expand All @@ -576,7 +576,7 @@ export type SentryBuildOptions = {
*
* @deprecated Use `webpack.automaticVercelMonitors` instead.
*/
automaticVercelMonitors?: boolean;
automaticVercelMonitors?: boolean; // TODO(v11): remove this option

/**
* When an error occurs during release creation or sourcemaps upload, the plugin will call this function.
Expand All @@ -603,20 +603,59 @@ export type SentryBuildOptions = {
/**
* Disables automatic injection of the route manifest into the client bundle.
*
* @deprecated Use `routeManifestInjection: false` instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a todo(v11) comment for removal pls. We should add a tracking issue for v11 for removing all the deprecated types then

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I will also add it for the other deprecated stuff.

*
* @default false
*/
disableManifestInjection?: boolean; // TODO(v11): remove this option

/**
* Options for the route manifest injection feature.
*
* The route manifest is a build-time generated mapping of your Next.js App Router
* routes that enables Sentry to group transactions by parameterized route names
* (e.g., `/users/:id` instead of `/users/123`, `/users/456`, etc.).
*
* **Disable this option if:**
* - You want to minimize client bundle size
* - You're experiencing build issues related to route scanning
* - You're using custom routing that the scanner can't detect
* - You prefer raw URLs in transaction names
* - You're only using Pages Router (this feature is only supported in the App Router)
* Set to `false` to disable route manifest injection entirely.
*
* @default false
* @example
* ```js
* // Disable route manifest injection
* routeManifestInjection: false
*
* // Exclude specific routes
* routeManifestInjection: {
* exclude: [
* '/admin', // Exact match
* /^\/internal\//, // Regex: all routes starting with /internal/
* /\/secret-/, // Regex: any route containing /secret-
* ]
* }
*
* // Exclude using a function
* routeManifestInjection: {
* exclude: (route) => route.includes('hidden')
* }
* ```
*/
disableManifestInjection?: boolean;
routeManifestInjection?:
| false
| {
/**
* Exclude specific routes from the route manifest.
*
* Use this option to prevent certain routes from being included in the client bundle's
* route manifest. This is useful for:
* - Hiding confidential or unreleased feature routes
* - Excluding internal/admin routes you don't want exposed
* - Reducing bundle size by omitting rarely-used routes
*
* Can be specified as:
* - An array of strings (exact match) or RegExp patterns
* - A function that receives a route path and returns `true` to exclude it
*/
exclude?: Array<string | RegExp> | ((route: string) => boolean);
};

/**
* Disables automatic injection of Sentry's Webpack configuration.
Expand All @@ -630,7 +669,7 @@ export type SentryBuildOptions = {
*
* @default false
*/
disableSentryWebpackConfig?: boolean;
disableSentryWebpackConfig?: boolean; // TODO(v11): remove this option

/**
* When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseSemver } from '@sentry/core';
import { isMatchingPattern, parseSemver } from '@sentry/core';
import { getSentryRelease } from '@sentry/node';
import { createRouteManifest } from '../manifest/createRouteManifest';
import type { RouteManifest } from '../manifest/types';
Expand Down Expand Up @@ -89,13 +89,59 @@ export function maybeCreateRouteManifest(
incomingUserNextConfigObject: NextConfigObject,
userSentryOptions: SentryBuildOptions,
): RouteManifest | undefined {
// Handle deprecated option with warning
// eslint-disable-next-line deprecation/deprecation
if (userSentryOptions.disableManifestInjection) {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/nextjs] The `disableManifestInjection` option is deprecated. Use `routeManifestInjection: false` instead.',
);
}

// If explicitly disabled, skip
if (userSentryOptions.routeManifestInjection === false) {
return undefined;
}

return createRouteManifest({
// Still check the deprecated option if the new option is not set
// eslint-disable-next-line deprecation/deprecation
if (userSentryOptions.routeManifestInjection === undefined && userSentryOptions.disableManifestInjection) {
return undefined;
}

const manifest = createRouteManifest({
basePath: incomingUserNextConfigObject.basePath,
});

// Apply route exclusion filter if configured
const excludeFilter = userSentryOptions.routeManifestInjection?.exclude;
return filterRouteManifest(manifest, excludeFilter);
}

type ExcludeFilter = ((route: string) => boolean) | (string | RegExp)[] | undefined;

/**
* Filters routes from the manifest based on the exclude filter.
* (Exported only for testing)
*/
export function filterRouteManifest(manifest: RouteManifest, excludeFilter: ExcludeFilter): RouteManifest {
if (!excludeFilter) {
return manifest;
}

const shouldExclude = (route: string): boolean => {
if (typeof excludeFilter === 'function') {
return excludeFilter(route);
}

return excludeFilter.some(pattern => isMatchingPattern(route, pattern));
};

return {
staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)),
dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)),
isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)),
};
}

/**
Expand Down
164 changes: 164 additions & 0 deletions packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import type { RouteManifest } from '../../../src/config/manifest/types';
import { filterRouteManifest } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils';

describe('routeManifestInjection.exclude', () => {
const mockManifest: RouteManifest = {
staticRoutes: [
{ path: '/' },
{ path: '/about' },
{ path: '/admin' },
{ path: '/admin/dashboard' },
{ path: '/internal/secret' },
{ path: '/public/page' },
],
dynamicRoutes: [
{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] },
{ path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] },
{ path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] },
],
isrRoutes: ['/blog', '/admin/reports', '/internal/stats'],
};

describe('with no filter', () => {
it('should return manifest unchanged', () => {
const result = filterRouteManifest(mockManifest, undefined);
expect(result).toEqual(mockManifest);
});
});

describe('with string patterns', () => {
it('should exclude routes containing the string pattern (substring match)', () => {
const result = filterRouteManifest(mockManifest, ['/admin']);

// All routes containing '/admin' are excluded
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
});

it('should exclude routes matching multiple string patterns', () => {
const result = filterRouteManifest(mockManifest, ['/about', '/blog']);

expect(result.staticRoutes.map(r => r.path)).toEqual([
'/',
'/admin',
'/admin/dashboard',
'/internal/secret',
'/public/page',
]);
expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']);
});

it('should match substrings anywhere in the route', () => {
// 'secret' matches '/internal/secret' and '/secret-feature/:id'
const result = filterRouteManifest(mockManifest, ['secret']);

expect(result.staticRoutes.map(r => r.path)).toEqual([
'/',
'/about',
'/admin',
'/admin/dashboard',
'/public/page',
]);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']);
});
});

describe('with regex patterns', () => {
it('should exclude routes matching regex', () => {
const result = filterRouteManifest(mockManifest, [/^\/admin/]);

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
});

it('should support multiple regex patterns', () => {
const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]);

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
expect(result.isrRoutes).toEqual(['/blog']);
});

it('should support partial regex matches', () => {
const result = filterRouteManifest(mockManifest, [/secret/]);

expect(result.staticRoutes.map(r => r.path)).toEqual([
'/',
'/about',
'/admin',
'/admin/dashboard',
'/public/page',
]);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']);
});

it('should handle case-insensitive regex', () => {
const result = filterRouteManifest(mockManifest, [/ADMIN/i]);

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
});
});

describe('with mixed patterns', () => {
it('should support both strings and regex', () => {
const result = filterRouteManifest(mockManifest, ['/about', /^\/admin/]);

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/internal/secret', '/public/page']);
});
});

describe('with function filter', () => {
it('should exclude routes where function returns true', () => {
const result = filterRouteManifest(mockManifest, (route: string) => route.includes('admin'));

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
});

it('should support complex filter logic', () => {
const result = filterRouteManifest(mockManifest, (route: string) => {
// Exclude anything with "secret" or "internal" or admin routes
return route.includes('secret') || route.includes('internal') || route.startsWith('/admin');
});

expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']);
expect(result.isrRoutes).toEqual(['/blog']);
});
});

describe('edge cases', () => {
it('should handle empty manifest', () => {
const emptyManifest: RouteManifest = {
staticRoutes: [],
dynamicRoutes: [],
isrRoutes: [],
};

const result = filterRouteManifest(emptyManifest, [/admin/]);
expect(result).toEqual(emptyManifest);
});

it('should handle filter that excludes everything', () => {
const result = filterRouteManifest(mockManifest, () => true);

expect(result.staticRoutes).toEqual([]);
expect(result.dynamicRoutes).toEqual([]);
expect(result.isrRoutes).toEqual([]);
});

it('should handle filter that excludes nothing', () => {
const result = filterRouteManifest(mockManifest, () => false);
expect(result).toEqual(mockManifest);
});

it('should handle empty filter array', () => {
const result = filterRouteManifest(mockManifest, []);
expect(result).toEqual(mockManifest);
});
});
});
Loading