Skip to content

Commit fd6ae0f

Browse files
authored
feat(nextjs): Add excludeServerRoutes config option (#6207)
Currently, in the nextjs SDK, we inject the user's `Sentry.init()` code (by way of their `sentry.server.config.js` file) into all serverside routes. This adds a new option to the `sentry` object in `next.config.js` which allows users to prevent specific routes from being instrumented in this way. In this option, excluded routes can be specified using either strings (which need to exactly match the route) or regexes. Note: Heavily inspired by #6125. h/t to @lforst for his work there. Compared to that PR, this one allows non-API routes to be excluded and allows excluded pages to be specified as routes rather than filepaths. (Using routes a) obviates the need for users to add `pages/` to the beginning of every entry, b) abstracts away the differences between windows and POSIX paths, and c) futureproofs users' config values against underlying changes to project file organization.) Docs for this feature are being added in getsentry/sentry-docs#5789. Fixes #6119. Fixes #5964.
1 parent 66bcbd7 commit fd6ae0f

File tree

10 files changed

+163
-8
lines changed

10 files changed

+163
-8
lines changed

packages/nextjs/src/config/loaders/proxyLoader.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { escapeStringForRegex, logger } from '@sentry/utils';
1+
import { escapeStringForRegex, logger, stringMatchesSomePattern } from '@sentry/utils';
22
import * as fs from 'fs';
33
import * as path from 'path';
44

@@ -8,6 +8,7 @@ import { LoaderThis } from './types';
88
type LoaderOptions = {
99
pagesDir: string;
1010
pageExtensionRegex: string;
11+
excludeServerRoutes: Array<RegExp | string>;
1112
};
1213

1314
/**
@@ -17,7 +18,11 @@ type LoaderOptions = {
1718
*/
1819
export default async function proxyLoader(this: LoaderThis<LoaderOptions>, userCode: string): Promise<string> {
1920
// We know one or the other will be defined, depending on the version of webpack being used
20-
const { pagesDir, pageExtensionRegex } = 'getOptions' in this ? this.getOptions() : this.query;
21+
const {
22+
pagesDir,
23+
pageExtensionRegex,
24+
excludeServerRoutes = [],
25+
} = 'getOptions' in this ? this.getOptions() : this.query;
2126

2227
// Get the parameterized route name from this page's filepath
2328
const parameterizedRoute = path
@@ -34,6 +39,11 @@ export default async function proxyLoader(this: LoaderThis<LoaderOptions>, userC
3439
// homepage), sub back in the root route
3540
.replace(/^$/, '/');
3641

42+
// Skip explicitly-ignored pages
43+
if (stringMatchesSomePattern(parameterizedRoute, excludeServerRoutes, true)) {
44+
return userCode;
45+
}
46+
3747
// We don't want to wrap twice (or infinitely), so in the proxy we add this query string onto references to the
3848
// wrapped file, so that we know that it's already been processed. (Adding this query string is also necessary to
3949
// convince webpack that it's a different file than the one it's in the middle of loading now, so that the originals

packages/nextjs/src/config/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ export type UserSentryOptions = {
5959

6060
// Automatically instrument Next.js data fetching methods and Next.js API routes
6161
autoInstrumentServerFunctions?: boolean;
62+
63+
// Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of
64+
// strings or regular expressions.
65+
//
66+
// NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths
67+
// (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,
68+
// exact match.
69+
excludeServerRoutes?: Array<RegExp | string>;
6270
};
6371

6472
export type NextConfigFunction = (phase: string, defaults: { defaultConfig: NextConfigObject }) => NextConfigObject;

packages/nextjs/src/config/webpack.ts

+30-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable max-lines */
22
import { getSentryRelease } from '@sentry/node';
3-
import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger } from '@sentry/utils';
3+
import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger, stringMatchesSomePattern } from '@sentry/utils';
44
import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
55
import * as chalk from 'chalk';
66
import * as fs from 'fs';
@@ -91,7 +91,11 @@ export function constructWebpackConfigFunction(
9191
use: [
9292
{
9393
loader: path.resolve(__dirname, 'loaders/proxyLoader.js'),
94-
options: { pagesDir, pageExtensionRegex },
94+
options: {
95+
pagesDir,
96+
pageExtensionRegex,
97+
excludeServerRoutes: userSentryOptions.excludeServerRoutes,
98+
},
9599
},
96100
],
97101
});
@@ -135,7 +139,7 @@ export function constructWebpackConfigFunction(
135139
// will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also
136140
// be fixed by using `bind`, but this is way simpler.)
137141
const origEntryProperty = newConfig.entry;
138-
newConfig.entry = async () => addSentryToEntryProperty(origEntryProperty, buildContext);
142+
newConfig.entry = async () => addSentryToEntryProperty(origEntryProperty, buildContext, userSentryOptions);
139143

140144
// Enable the Sentry plugin (which uploads source maps to Sentry when not in dev) by default
141145
if (shouldEnableWebpackPlugin(buildContext, userSentryOptions)) {
@@ -248,6 +252,7 @@ function findTranspilationRules(rules: WebpackModuleRule[] | undefined, projectD
248252
async function addSentryToEntryProperty(
249253
currentEntryProperty: WebpackEntryProperty,
250254
buildContext: BuildContext,
255+
userSentryOptions: UserSentryOptions,
251256
): Promise<EntryPropertyObject> {
252257
// The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs
253258
// sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether
@@ -268,8 +273,18 @@ async function addSentryToEntryProperty(
268273

269274
// inject into all entry points which might contain user's code
270275
for (const entryPointName in newEntryProperty) {
271-
if (shouldAddSentryToEntryPoint(entryPointName, isServer)) {
276+
if (shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes)) {
272277
addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject);
278+
} else {
279+
if (
280+
isServer &&
281+
// If the user has asked to exclude pages, confirm for them that it's worked
282+
userSentryOptions.excludeServerRoutes &&
283+
// We always skip these, so it's not worth telling the user that we've done so
284+
!['pages/_app', 'pages/_document'].includes(entryPointName)
285+
) {
286+
__DEBUG_BUILD__ && logger.log(`Skipping Sentry injection for ${entryPointName.replace(/^pages/, '')}`);
287+
}
273288
}
274289
}
275290

@@ -377,13 +392,21 @@ function checkWebpackPluginOverrides(
377392
*
378393
* @param entryPointName The name of the entry point in question
379394
* @param isServer Whether or not this function is being called in the context of a server build
395+
* @param excludeServerRoutes A list of excluded serverside entrypoints provided by the user
380396
* @returns `true` if sentry code should be injected, and `false` otherwise
381397
*/
382-
function shouldAddSentryToEntryPoint(entryPointName: string, isServer: boolean): boolean {
398+
function shouldAddSentryToEntryPoint(
399+
entryPointName: string,
400+
isServer: boolean,
401+
excludeServerRoutes: Array<string | RegExp> = [],
402+
): boolean {
383403
// On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions).
384404
if (isServer) {
385405
const entryPointRoute = entryPointName.replace(/^pages/, '');
386406
if (
407+
// User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes,
408+
// which don't have the `pages` prefix.)
409+
stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true) ||
387410
// All non-API pages contain both of these components, and we don't want to inject more than once, so as long as
388411
// we're doing the individual pages, it's fine to skip these. (Note: Even if a given user doesn't have either or
389412
// both of these in their `pages/` folder, they'll exist as entrypoints because nextjs will supply default
@@ -462,7 +485,8 @@ export function getWebpackPluginOptions(
462485
configFile: hasSentryProperties ? 'sentry.properties' : undefined,
463486
stripPrefix: ['webpack://_N_E/'],
464487
urlPrefix,
465-
entries: (entryPointName: string) => shouldAddSentryToEntryPoint(entryPointName, isServer),
488+
entries: (entryPointName: string) =>
489+
shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes),
466490
release: getSentryRelease(buildId),
467491
dryRun: isDev,
468492
});

packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts

+25
Original file line numberDiff line numberDiff line change
@@ -241,5 +241,30 @@ describe('constructWebpackConfigFunction()', () => {
241241
simulatorBundle: './src/simulator/index.ts',
242242
});
243243
});
244+
245+
it('does not inject into routes included in `excludeServerRoutes`', async () => {
246+
const nextConfigWithExcludedRoutes = {
247+
...exportedNextConfig,
248+
sentry: {
249+
excludeServerRoutes: [/simulator/],
250+
},
251+
};
252+
const finalWebpackConfig = await materializeFinalWebpackConfig({
253+
exportedNextConfig: nextConfigWithExcludedRoutes,
254+
incomingWebpackConfig: serverWebpackConfig,
255+
incomingWebpackBuildContext: serverBuildContext,
256+
});
257+
258+
expect(finalWebpackConfig.entry).toEqual(
259+
expect.objectContaining({
260+
'pages/simulator/leaderboard': {
261+
import: expect.not.arrayContaining([serverConfigFilePath]),
262+
},
263+
'pages/api/simulator/dogStats/[name]': {
264+
import: expect.not.arrayContaining([serverConfigFilePath]),
265+
},
266+
}),
267+
);
268+
});
244269
});
245270
});

packages/nextjs/test/integration/next.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const moduleExports = {
99
// Suppress the warning message from `handleSourcemapHidingOptionWarning` in `src/config/webpack.ts`
1010
// TODO (v8): This can come out in v8, because this option will get a default value
1111
hideSourceMaps: false,
12+
excludeServerRoutes: ['/api/excludedEndpoints/excludedWithString', /\/api\/excludedEndpoints\/excludedWithRegExp/],
1213
},
1314
};
1415
const SentryWebpackPluginOptions = {

packages/nextjs/test/integration/next10.config.template

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const moduleExports = {
1010
// Suppress the warning message from `handleSourcemapHidingOptionWarning` in `src/config/webpack.ts`
1111
// TODO (v8): This can come out in v8, because this option will get a default value
1212
hideSourceMaps: false,
13+
excludeServerRoutes: [
14+
'/api/excludedEndpoints/excludedWithString',
15+
/\/api\/excludedEndpoints\/excludedWithRegExp/,
16+
],
1317
},
1418
};
1519

packages/nextjs/test/integration/next11.config.template

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const moduleExports = {
1111
// Suppress the warning message from `handleSourcemapHidingOptionWarning` in `src/config/webpack.ts`
1212
// TODO (v8): This can come out in v8, because this option will get a default value
1313
hideSourceMaps: false,
14+
excludeServerRoutes: [
15+
'/api/excludedEndpoints/excludedWithString',
16+
/\/api\/excludedEndpoints\/excludedWithRegExp/,
17+
],
1418
},
1519
};
1620

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// This file will test the `excludeServerRoutes` option when a route is provided as a RegExp.
2+
const handler = async (): Promise<void> => {
3+
throw new Error('API Error');
4+
};
5+
6+
export default handler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// This file will test the `excludeServerRoutes` option when a route is provided as a string.
2+
const handler = async (): Promise<void> => {
3+
throw new Error('API Error');
4+
};
5+
6+
export default handler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const assert = require('assert');
2+
3+
const { sleep } = require('../utils/common');
4+
const { getAsync, interceptEventRequest, interceptTracingRequest } = require('../utils/server');
5+
6+
module.exports = async ({ url: urlBase, argv }) => {
7+
const regExpUrl = `${urlBase}/api/excludedEndpoints/excludedWithRegExp`;
8+
const stringUrl = `${urlBase}/api/excludedEndpoints/excludedWithString`;
9+
10+
const capturedRegExpErrorRequest = interceptEventRequest(
11+
{
12+
exception: {
13+
values: [
14+
{
15+
type: 'Error',
16+
value: 'API Error',
17+
},
18+
],
19+
},
20+
tags: {
21+
runtime: 'node',
22+
},
23+
request: {
24+
url: regExpUrl,
25+
method: 'GET',
26+
},
27+
transaction: 'GET /api/excludedEndpoints/excludedWithRegExp',
28+
},
29+
argv,
30+
'excluded API endpoint via RegExp',
31+
);
32+
33+
const capturedStringErrorRequest = interceptEventRequest(
34+
{
35+
exception: {
36+
values: [
37+
{
38+
type: 'Error',
39+
value: 'API Error',
40+
},
41+
],
42+
},
43+
tags: {
44+
runtime: 'node',
45+
},
46+
request: {
47+
url: regExpUrl,
48+
method: 'GET',
49+
},
50+
transaction: 'GET /api/excludedEndpoints/excludedWithString',
51+
},
52+
argv,
53+
'excluded API endpoint via String',
54+
);
55+
56+
await Promise.all([getAsync(regExpUrl), getAsync(stringUrl)]);
57+
await sleep(250);
58+
59+
assert.ok(
60+
!capturedRegExpErrorRequest.isDone(),
61+
'Did intercept error request even though route should be excluded (RegExp)',
62+
);
63+
assert.ok(
64+
!capturedStringErrorRequest.isDone(),
65+
'Did intercept error request even though route should be excluded (String)',
66+
);
67+
};

0 commit comments

Comments
 (0)