Skip to content

Commit 77de64e

Browse files
authored
feat(nextjs): Add SDK to serverside app directory (#6927)
1 parent a591f51 commit 77de64e

File tree

7 files changed

+144
-123
lines changed

7 files changed

+144
-123
lines changed

packages/nextjs/src/config/webpack.ts

+93-101
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import type {
2222
WebpackModuleRule,
2323
} from './types';
2424

25-
export { SentryWebpackPlugin };
25+
const RUNTIME_TO_SDK_ENTRYPOINT_MAP = {
26+
browser: './client',
27+
node: './server',
28+
edge: './edge',
29+
} as const;
2630

2731
// TODO: merge default SentryWebpackPlugin ignore with their SentryWebpackPlugin ignore or ignoreFile
2832
// TODO: merge default SentryWebpackPlugin include with their SentryWebpackPlugin include
@@ -53,6 +57,7 @@ export function constructWebpackConfigFunction(
5357
buildContext: BuildContext,
5458
): WebpackConfigObject {
5559
const { isServer, dev: isDev, dir: projectDir } = buildContext;
60+
const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'node') : 'browser';
5661

5762
let rawNewConfig = { ...incomingConfig };
5863

@@ -67,82 +72,77 @@ export function constructWebpackConfigFunction(
6772
const newConfig = setUpModuleRules(rawNewConfig);
6873

6974
// Add a loader which will inject code that sets global values
70-
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions);
75+
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext);
7176

7277
newConfig.module.rules.push({
7378
test: /node_modules[/\\]@sentry[/\\]nextjs/,
7479
use: [
7580
{
7681
loader: path.resolve(__dirname, 'loaders', 'sdkMultiplexerLoader.js'),
7782
options: {
78-
importTarget: buildContext.nextRuntime === 'edge' ? './edge' : './client',
83+
importTarget: RUNTIME_TO_SDK_ENTRYPOINT_MAP[runtime],
7984
},
8085
},
8186
],
8287
});
8388

84-
if (isServer) {
85-
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
86-
let pagesDirPath: string;
87-
if (
88-
fs.existsSync(path.join(projectDir, 'pages')) &&
89-
fs.lstatSync(path.join(projectDir, 'pages')).isDirectory()
90-
) {
91-
pagesDirPath = path.join(projectDir, 'pages');
92-
} else {
93-
pagesDirPath = path.join(projectDir, 'src', 'pages');
94-
}
89+
if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) {
90+
let pagesDirPath: string;
91+
if (fs.existsSync(path.join(projectDir, 'pages')) && fs.lstatSync(path.join(projectDir, 'pages')).isDirectory()) {
92+
pagesDirPath = path.join(projectDir, 'pages');
93+
} else {
94+
pagesDirPath = path.join(projectDir, 'src', 'pages');
95+
}
9596

96-
const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js');
97-
const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts');
98-
99-
// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161
100-
const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js'];
101-
const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`);
102-
const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|');
103-
104-
// It is very important that we insert our loader at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened.
105-
newConfig.module.rules.unshift({
106-
test: resourcePath => {
107-
// We generally want to apply the loader to all API routes, pages and to the middleware file.
108-
109-
// `resourcePath` may be an absolute path or a path relative to the context of the webpack config
110-
let absoluteResourcePath: string;
111-
if (path.isAbsolute(resourcePath)) {
112-
absoluteResourcePath = resourcePath;
113-
} else {
114-
absoluteResourcePath = path.join(projectDir, resourcePath);
115-
}
116-
const normalizedAbsoluteResourcePath = path.normalize(absoluteResourcePath);
117-
118-
if (
119-
// Match everything inside pages/ with the appropriate file extension
120-
normalizedAbsoluteResourcePath.startsWith(pagesDirPath) &&
121-
dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
122-
) {
123-
return true;
124-
} else if (
125-
// Match middleware.js and middleware.ts
126-
normalizedAbsoluteResourcePath === middlewareJsPath ||
127-
normalizedAbsoluteResourcePath === middlewareTsPath
128-
) {
129-
return userSentryOptions.autoInstrumentMiddleware ?? true;
130-
} else {
131-
return false;
132-
}
133-
},
134-
use: [
135-
{
136-
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
137-
options: {
138-
pagesDir: pagesDirPath,
139-
pageExtensionRegex,
140-
excludeServerRoutes: userSentryOptions.excludeServerRoutes,
141-
},
97+
const middlewareJsPath = path.join(pagesDirPath, '..', 'middleware.js');
98+
const middlewareTsPath = path.join(pagesDirPath, '..', 'middleware.ts');
99+
100+
// Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161
101+
const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js'];
102+
const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`);
103+
const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|');
104+
105+
// It is very important that we insert our loader at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened.
106+
newConfig.module.rules.unshift({
107+
test: resourcePath => {
108+
// We generally want to apply the loader to all API routes, pages and to the middleware file.
109+
110+
// `resourcePath` may be an absolute path or a path relative to the context of the webpack config
111+
let absoluteResourcePath: string;
112+
if (path.isAbsolute(resourcePath)) {
113+
absoluteResourcePath = resourcePath;
114+
} else {
115+
absoluteResourcePath = path.join(projectDir, resourcePath);
116+
}
117+
const normalizedAbsoluteResourcePath = path.normalize(absoluteResourcePath);
118+
119+
if (
120+
// Match everything inside pages/ with the appropriate file extension
121+
normalizedAbsoluteResourcePath.startsWith(pagesDirPath) &&
122+
dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
123+
) {
124+
return true;
125+
} else if (
126+
// Match middleware.js and middleware.ts
127+
normalizedAbsoluteResourcePath === middlewareJsPath ||
128+
normalizedAbsoluteResourcePath === middlewareTsPath
129+
) {
130+
return userSentryOptions.autoInstrumentMiddleware ?? true;
131+
} else {
132+
return false;
133+
}
134+
},
135+
use: [
136+
{
137+
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
138+
options: {
139+
pagesDir: pagesDirPath,
140+
pageExtensionRegex,
141+
excludeServerRoutes: userSentryOptions.excludeServerRoutes,
142142
},
143-
],
144-
});
145-
}
143+
},
144+
],
145+
});
146146
}
147147

148148
// The SDK uses syntax (ES6 and ES6+ features like object spread) which isn't supported by older browsers. For users
@@ -303,7 +303,8 @@ async function addSentryToEntryProperty(
303303
// we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function
304304
// options. See https://webpack.js.org/configuration/entry-context/#entry.
305305

306-
const { isServer, dir: projectDir, dev: isDev, nextRuntime } = buildContext;
306+
const { isServer, dir: projectDir, nextRuntime } = buildContext;
307+
const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'node') : 'browser';
307308

308309
const newEntryProperty =
309310
typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty };
@@ -321,7 +322,7 @@ async function addSentryToEntryProperty(
321322

322323
// inject into all entry points which might contain user's code
323324
for (const entryPointName in newEntryProperty) {
324-
if (shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev)) {
325+
if (shouldAddSentryToEntryPoint(entryPointName, runtime, userSentryOptions.excludeServerRoutes ?? [])) {
325326
addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject);
326327
} else {
327328
if (
@@ -455,49 +456,31 @@ function checkWebpackPluginOverrides(
455456
*/
456457
function shouldAddSentryToEntryPoint(
457458
entryPointName: string,
458-
isServer: boolean,
459-
excludeServerRoutes: Array<string | RegExp> = [],
460-
isDev: boolean,
459+
runtime: 'node' | 'browser' | 'edge',
460+
excludeServerRoutes: Array<string | RegExp>,
461461
): boolean {
462462
// On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions).
463-
if (isServer) {
464-
if (entryPointName === 'middleware') {
465-
return true;
466-
}
467-
468-
const entryPointRoute = entryPointName.replace(/^pages/, '');
469-
463+
if (runtime === 'node') {
470464
// User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes,
471465
// which don't have the `pages` prefix.)
466+
const entryPointRoute = entryPointName.replace(/^pages/, '');
472467
if (stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true)) {
473468
return false;
474469
}
475470

476-
// In dev mode, page routes aren't considered entrypoints so we inject the init call in the `/_app` entrypoint which
477-
// always exists, even if the user didn't add a `_app` page themselves
478-
if (isDev) {
479-
return entryPointRoute === '/_app';
480-
}
481-
482-
if (
483-
// All non-API pages contain both of these components, and we don't want to inject more than once, so as long as
484-
// we're doing the individual pages, it's fine to skip these. (Note: Even if a given user doesn't have either or
485-
// both of these in their `pages/` folder, they'll exist as entrypoints because nextjs will supply default
486-
// versions.)
487-
entryPointRoute === '/_app' ||
488-
entryPointRoute === '/_document' ||
489-
!entryPointName.startsWith('pages/')
490-
) {
491-
return false;
492-
}
493-
494-
// We want to inject Sentry into all other pages
495-
return true;
496-
} else {
471+
// This expression will implicitly include `pages/_app` which is called for all serverside routes and pages
472+
// regardless whether or not the user has a`_app` file.
473+
return entryPointName.startsWith('pages/');
474+
} else if (runtime === 'browser') {
497475
return (
498-
entryPointName === 'pages/_app' || // entrypoint for `/pages` pages
476+
entryPointName === 'main' || // entrypoint for `/pages` pages
499477
entryPointName === 'main-app' // entrypoint for `/app` pages
500478
);
479+
} else {
480+
// User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes,
481+
// which don't have the `pages` prefix.)
482+
const entryPointRoute = entryPointName.replace(/^pages/, '');
483+
return !stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true);
501484
}
502485
}
503486

@@ -526,13 +509,19 @@ export function getWebpackPluginOptions(
526509

527510
const serverInclude = isServerless
528511
? [{ paths: [`${distDirAbsPath}/serverless/`], urlPrefix: `${urlPrefix}/serverless` }]
529-
: [{ paths: [`${distDirAbsPath}/server/pages/`], urlPrefix: `${urlPrefix}/server/pages` }].concat(
512+
: [
513+
{ paths: [`${distDirAbsPath}/server/pages/`], urlPrefix: `${urlPrefix}/server/pages` },
514+
{ paths: [`${distDirAbsPath}/server/app/`], urlPrefix: `${urlPrefix}/server/app` },
515+
].concat(
530516
isWebpack5 ? [{ paths: [`${distDirAbsPath}/server/chunks/`], urlPrefix: `${urlPrefix}/server/chunks` }] : [],
531517
);
532518

533519
const clientInclude = userSentryOptions.widenClientFileUpload
534520
? [{ paths: [`${distDirAbsPath}/static/chunks`], urlPrefix: `${urlPrefix}/static/chunks` }]
535-
: [{ paths: [`${distDirAbsPath}/static/chunks/pages`], urlPrefix: `${urlPrefix}/static/chunks/pages` }];
521+
: [
522+
{ paths: [`${distDirAbsPath}/static/chunks/pages`], urlPrefix: `${urlPrefix}/static/chunks/pages` },
523+
{ paths: [`${distDirAbsPath}/static/chunks/app`], urlPrefix: `${urlPrefix}/static/chunks/app` },
524+
];
536525

537526
const defaultPluginOptions = dropUndefinedKeys({
538527
include: isServer ? serverInclude : clientInclude,
@@ -550,8 +539,7 @@ export function getWebpackPluginOptions(
550539
configFile: hasSentryProperties ? 'sentry.properties' : undefined,
551540
stripPrefix: ['webpack://_N_E/'],
552541
urlPrefix,
553-
entries: (entryPointName: string) =>
554-
shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev),
542+
entries: [], // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead.
555543
release: getSentryRelease(buildId),
556544
dryRun: isDev,
557545
});
@@ -675,12 +663,16 @@ function addValueInjectionLoader(
675663
newConfig: WebpackConfigObjectWithModuleRules,
676664
userNextConfig: NextConfigObject,
677665
userSentryOptions: UserSentryOptions,
666+
buildContext: BuildContext,
678667
): void {
679668
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';
680669

681670
const isomorphicValues = {
682671
// `rewritesTunnel` set by the user in Next.js config
683672
__sentryRewritesTunnelPath__: userSentryOptions.tunnelRoute,
673+
674+
// The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead.
675+
SENTRY_RELEASE: { id: getSentryRelease(buildContext.buildId) },
684676
};
685677

686678
const serverValues = {

packages/nextjs/test/config/testUtils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
12
import type { WebpackPluginInstance } from 'webpack';
23

34
import type {
@@ -9,7 +10,6 @@ import type {
910
WebpackConfigObject,
1011
WebpackConfigObjectWithModuleRules,
1112
} from '../../src/config/types';
12-
import type { SentryWebpackPlugin } from '../../src/config/webpack';
1313
import { constructWebpackConfigFunction } from '../../src/config/webpack';
1414
import { withSentryConfig } from '../../src/config/withSentryConfig';
1515
import { defaultRuntimePhase, defaultsObject } from './fixtures';

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

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// mock helper functions not tested directly in this file
22
import '../mocks';
33

4-
import { SentryWebpackPlugin } from '../../../src/config/webpack';
4+
import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
5+
56
import {
67
CLIENT_SDK_CONFIG_FILE,
78
clientBuildContext,
@@ -138,7 +139,7 @@ describe('constructWebpackConfigFunction()', () => {
138139
);
139140
});
140141

141-
it('injects user config file into `_app` in client bundle but not in server bundle', async () => {
142+
it('injects user config file into `_app` in server bundle but not in client bundle', async () => {
142143
const finalServerWebpackConfig = await materializeFinalWebpackConfig({
143144
exportedNextConfig,
144145
incomingWebpackConfig: serverWebpackConfig,
@@ -152,12 +153,12 @@ describe('constructWebpackConfigFunction()', () => {
152153

153154
expect(finalServerWebpackConfig.entry).toEqual(
154155
expect.objectContaining({
155-
'pages/_app': expect.not.arrayContaining([serverConfigFilePath]),
156+
'pages/_app': expect.arrayContaining([serverConfigFilePath]),
156157
}),
157158
);
158159
expect(finalClientWebpackConfig.entry).toEqual(
159160
expect.objectContaining({
160-
'pages/_app': expect.arrayContaining([clientConfigFilePath]),
161+
'pages/_app': expect.not.arrayContaining([clientConfigFilePath]),
161162
}),
162163
);
163164
});
@@ -232,9 +233,9 @@ describe('constructWebpackConfigFunction()', () => {
232233
});
233234

234235
expect(finalWebpackConfig.entry).toEqual({
235-
main: './src/index.ts',
236+
main: ['./sentry.client.config.js', './src/index.ts'],
236237
// only _app has config file injected
237-
'pages/_app': [clientConfigFilePath, 'next-client-pages-loader?page=%2F_app'],
238+
'pages/_app': 'next-client-pages-loader?page=%2F_app',
238239
'pages/_error': 'next-client-pages-loader?page=%2F_error',
239240
'pages/sniffTour': ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js'],
240241
'pages/simulator/leaderboard': {

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

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
12
import * as fs from 'fs';
23
import * as os from 'os';
34
import * as path from 'path';
45

56
import type { BuildContext, ExportedNextConfig } from '../../../src/config/types';
6-
import { getUserConfigFile, getWebpackPluginOptions, SentryWebpackPlugin } from '../../../src/config/webpack';
7+
import { getUserConfigFile, getWebpackPluginOptions } from '../../../src/config/webpack';
78
import {
89
clientBuildContext,
910
clientWebpackConfig,
@@ -36,7 +37,7 @@ describe('Sentry webpack plugin config', () => {
3637
authToken: 'dogsarebadatkeepingsecrets', // picked up from env
3738
stripPrefix: ['webpack://_N_E/'], // default
3839
urlPrefix: '~/_next', // default
39-
entries: expect.any(Function), // default, tested separately elsewhere
40+
entries: [],
4041
release: 'doGsaREgReaT', // picked up from env
4142
dryRun: false, // based on buildContext.dev being false
4243
}),
@@ -78,6 +79,7 @@ describe('Sentry webpack plugin config', () => {
7879

7980
expect(sentryWebpackPluginInstance.options.include).toEqual([
8081
{ paths: [`${clientBuildContext.dir}/.next/static/chunks/pages`], urlPrefix: '~/_next/static/chunks/pages' },
82+
{ paths: [`${clientBuildContext.dir}/.next/static/chunks/app`], urlPrefix: '~/_next/static/chunks/app' },
8183
]);
8284
});
8385

@@ -141,6 +143,7 @@ describe('Sentry webpack plugin config', () => {
141143

142144
expect(sentryWebpackPluginInstance.options.include).toEqual([
143145
{ paths: [`${serverBuildContextWebpack4.dir}/.next/server/pages/`], urlPrefix: '~/_next/server/pages' },
146+
{ paths: [`${serverBuildContextWebpack4.dir}/.next/server/app/`], urlPrefix: '~/_next/server/app' },
144147
]);
145148
});
146149

@@ -158,6 +161,7 @@ describe('Sentry webpack plugin config', () => {
158161

159162
expect(sentryWebpackPluginInstance.options.include).toEqual([
160163
{ paths: [`${serverBuildContext.dir}/.next/server/pages/`], urlPrefix: '~/_next/server/pages' },
164+
{ paths: [`${serverBuildContext.dir}/.next/server/app/`], urlPrefix: '~/_next/server/app' },
161165
{ paths: [`${serverBuildContext.dir}/.next/server/chunks/`], urlPrefix: '~/_next/server/chunks' },
162166
]);
163167
});

0 commit comments

Comments
 (0)