diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 085e5c874184..cdc6e68f053d 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -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. @@ -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. @@ -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 @@ -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`. @@ -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. @@ -603,20 +603,59 @@ export type SentryBuildOptions = { /** * Disables automatic injection of the route manifest into the client bundle. * + * @deprecated Use `routeManifestInjection: false` instead. + * + * @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 | ((route: string) => boolean); + }; /** * Disables automatic injection of Sentry's Webpack configuration. @@ -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 diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 469d3e02cc4f..b56fa1894362 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -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'; @@ -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)), + }; } /** diff --git a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts new file mode 100644 index 000000000000..a22af530b332 --- /dev/null +++ b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts @@ -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); + }); + }); +});