Skip to content

Commit 2e42753

Browse files
committed
feat: added routeManifestInjection exclude options to disable manifest for specific pages
1 parent 83445eb commit 2e42753

3 files changed

Lines changed: 257 additions & 9 deletions

File tree

packages/nextjs/src/config/types.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -603,20 +603,59 @@ export type SentryBuildOptions = {
603603
/**
604604
* Disables automatic injection of the route manifest into the client bundle.
605605
*
606+
* @deprecated Use `routeManifestInjection: false` instead.
607+
*
608+
* @default false
609+
*/
610+
disableManifestInjection?: boolean;
611+
612+
/**
613+
* Options for the route manifest injection feature.
614+
*
606615
* The route manifest is a build-time generated mapping of your Next.js App Router
607616
* routes that enables Sentry to group transactions by parameterized route names
608617
* (e.g., `/users/:id` instead of `/users/123`, `/users/456`, etc.).
609618
*
610-
* **Disable this option if:**
611-
* - You want to minimize client bundle size
612-
* - You're experiencing build issues related to route scanning
613-
* - You're using custom routing that the scanner can't detect
614-
* - You prefer raw URLs in transaction names
615-
* - You're only using Pages Router (this feature is only supported in the App Router)
619+
* Set to `false` to disable route manifest injection entirely.
616620
*
617-
* @default false
621+
* @example
622+
* ```js
623+
* // Disable route manifest injection
624+
* routeManifestInjection: false
625+
*
626+
* // Exclude specific routes
627+
* routeManifestInjection: {
628+
* exclude: [
629+
* '/admin', // Exact match
630+
* /^\/internal\//, // Regex: all routes starting with /internal/
631+
* /\/secret-/, // Regex: any route containing /secret-
632+
* ]
633+
* }
634+
*
635+
* // Exclude using a function
636+
* routeManifestInjection: {
637+
* exclude: (route) => route.includes('hidden')
638+
* }
639+
* ```
618640
*/
619-
disableManifestInjection?: boolean;
641+
routeManifestInjection?:
642+
| false
643+
| {
644+
/**
645+
* Exclude specific routes from the route manifest.
646+
*
647+
* Use this option to prevent certain routes from being included in the client bundle's
648+
* route manifest. This is useful for:
649+
* - Hiding confidential or unreleased feature routes
650+
* - Excluding internal/admin routes you don't want exposed
651+
* - Reducing bundle size by omitting rarely-used routes
652+
*
653+
* Can be specified as:
654+
* - An array of strings (exact match) or RegExp patterns
655+
* - A function that receives a route path and returns `true` to exclude it
656+
*/
657+
exclude?: Array<string | RegExp> | ((route: string) => boolean);
658+
};
620659

621660
/**
622661
* Disables automatic injection of Sentry's Webpack configuration.

packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,47 @@ export function maybeCreateRouteManifest(
8989
incomingUserNextConfigObject: NextConfigObject,
9090
userSentryOptions: SentryBuildOptions,
9191
): RouteManifest | undefined {
92+
// Handle deprecated option with warning
9293
if (userSentryOptions.disableManifestInjection) {
94+
// eslint-disable-next-line no-console
95+
console.warn(
96+
'[@sentry/nextjs] The `disableManifestInjection` option is deprecated. Use `routeManifestInjection: false` instead.',
97+
);
98+
}
99+
100+
// Check if manifest injection is disabled (new option takes precedence)
101+
if (userSentryOptions.routeManifestInjection === false || userSentryOptions.disableManifestInjection) {
93102
return undefined;
94103
}
95104

96-
return createRouteManifest({
105+
const manifest = createRouteManifest({
97106
basePath: incomingUserNextConfigObject.basePath,
98107
});
108+
109+
// Apply route exclusion filter if configured
110+
const excludeFilter = userSentryOptions.routeManifestInjection?.exclude;
111+
if (!excludeFilter) {
112+
return manifest;
113+
}
114+
115+
const shouldExclude = (route: string): boolean => {
116+
if (typeof excludeFilter === 'function') {
117+
return excludeFilter(route);
118+
}
119+
120+
return excludeFilter.some(pattern => {
121+
if (typeof pattern === 'string') {
122+
return route === pattern;
123+
}
124+
return pattern.test(route);
125+
});
126+
};
127+
128+
return {
129+
staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)),
130+
dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)),
131+
isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)),
132+
};
99133
}
100134

101135
/**
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { RouteManifest } from '../../../src/config/manifest/types';
3+
import type { SentryBuildOptions } from '../../../src/config/types';
4+
5+
type RouteManifestInjectionOptions = Exclude<SentryBuildOptions['routeManifestInjection'], false | undefined>;
6+
type ExcludeFilter = RouteManifestInjectionOptions['exclude'];
7+
8+
// Inline the filtering logic for unit testing
9+
// This mirrors what maybeCreateRouteManifest does internally
10+
function filterManifest(manifest: RouteManifest, excludeFilter: ExcludeFilter): RouteManifest {
11+
if (!excludeFilter) {
12+
return manifest;
13+
}
14+
15+
const shouldExclude = (route: string): boolean => {
16+
if (typeof excludeFilter === 'function') {
17+
return excludeFilter(route);
18+
}
19+
20+
return excludeFilter.some((pattern: string | RegExp) => {
21+
if (typeof pattern === 'string') {
22+
return route === pattern;
23+
}
24+
return pattern.test(route);
25+
});
26+
};
27+
28+
return {
29+
staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)),
30+
dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)),
31+
isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)),
32+
};
33+
}
34+
35+
describe('routeManifestInjection.exclude', () => {
36+
const mockManifest: RouteManifest = {
37+
staticRoutes: [
38+
{ path: '/' },
39+
{ path: '/about' },
40+
{ path: '/admin' },
41+
{ path: '/admin/dashboard' },
42+
{ path: '/internal/secret' },
43+
{ path: '/public/page' },
44+
],
45+
dynamicRoutes: [
46+
{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] },
47+
{ path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] },
48+
{ path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] },
49+
],
50+
isrRoutes: ['/blog', '/admin/reports', '/internal/stats'],
51+
};
52+
53+
describe('with no filter', () => {
54+
it('should return manifest unchanged', () => {
55+
const result = filterManifest(mockManifest, undefined);
56+
expect(result).toEqual(mockManifest);
57+
});
58+
});
59+
60+
describe('with string patterns', () => {
61+
it('should exclude exact string matches', () => {
62+
const result = filterManifest(mockManifest, ['/admin']);
63+
64+
expect(result.staticRoutes.map(r => r.path)).toEqual([
65+
'/',
66+
'/about',
67+
'/admin/dashboard', // Not excluded - not exact match
68+
'/internal/secret',
69+
'/public/page',
70+
]);
71+
});
72+
73+
it('should exclude multiple exact matches', () => {
74+
const result = filterManifest(mockManifest, ['/admin', '/about', '/blog']);
75+
76+
expect(result.staticRoutes.map(r => r.path)).toEqual([
77+
'/',
78+
'/admin/dashboard',
79+
'/internal/secret',
80+
'/public/page',
81+
]);
82+
expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']);
83+
});
84+
});
85+
86+
describe('with regex patterns', () => {
87+
it('should exclude routes matching regex', () => {
88+
const result = filterManifest(mockManifest, [/^\/admin/]);
89+
90+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
91+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
92+
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
93+
});
94+
95+
it('should support multiple regex patterns', () => {
96+
const result = filterManifest(mockManifest, [/^\/admin/, /^\/internal/]);
97+
98+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
99+
expect(result.isrRoutes).toEqual(['/blog']);
100+
});
101+
102+
it('should support partial regex matches', () => {
103+
const result = filterManifest(mockManifest, [/secret/]);
104+
105+
expect(result.staticRoutes.map(r => r.path)).toEqual([
106+
'/',
107+
'/about',
108+
'/admin',
109+
'/admin/dashboard',
110+
'/public/page',
111+
]);
112+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']);
113+
});
114+
});
115+
116+
describe('with mixed patterns', () => {
117+
it('should support both strings and regex', () => {
118+
const result = filterManifest(mockManifest, ['/about', /^\/admin/]);
119+
120+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/internal/secret', '/public/page']);
121+
});
122+
});
123+
124+
describe('with function filter', () => {
125+
it('should exclude routes where function returns true', () => {
126+
const result = filterManifest(mockManifest, (route: string) => route.includes('admin'));
127+
128+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']);
129+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']);
130+
expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']);
131+
});
132+
133+
it('should support complex filter logic', () => {
134+
const result = filterManifest(mockManifest, (route: string) => {
135+
// Exclude anything with "secret" or "internal" or admin routes
136+
return route.includes('secret') || route.includes('internal') || route.startsWith('/admin');
137+
});
138+
139+
expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']);
140+
expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']);
141+
expect(result.isrRoutes).toEqual(['/blog']);
142+
});
143+
});
144+
145+
describe('edge cases', () => {
146+
it('should handle empty manifest', () => {
147+
const emptyManifest: RouteManifest = {
148+
staticRoutes: [],
149+
dynamicRoutes: [],
150+
isrRoutes: [],
151+
};
152+
153+
const result = filterManifest(emptyManifest, [/admin/]);
154+
expect(result).toEqual(emptyManifest);
155+
});
156+
157+
it('should handle filter that excludes everything', () => {
158+
const result = filterManifest(mockManifest, () => true);
159+
160+
expect(result.staticRoutes).toEqual([]);
161+
expect(result.dynamicRoutes).toEqual([]);
162+
expect(result.isrRoutes).toEqual([]);
163+
});
164+
165+
it('should handle filter that excludes nothing', () => {
166+
const result = filterManifest(mockManifest, () => false);
167+
expect(result).toEqual(mockManifest);
168+
});
169+
170+
it('should handle empty filter array', () => {
171+
const result = filterManifest(mockManifest, []);
172+
expect(result).toEqual(mockManifest);
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)