Skip to content

Commit 965714e

Browse files
authored
fix(nextjs): Support custom distDir values in default RewriteFrames integration (#4017)
This is a follow-up to #3990, which makes sure sourcemaps get uploaded from the correct spot when using the nextjs `distDir` option[1]. To make sure that the filenames in stacktrace frames match the names of said sourcemaps, we use the `RewriteFrames` integration. Up until now, we've been hardcoding the replacement it should make, based on nextjs's default output directory. This makes it dynamic, by injecting code at build time which adds the relevant value into `global`, where it is accessible at runtime when we're instantiating the integration. [1] https://nextjs.org/docs/api-reference/next.config.js/setting-a-custom-build-directory
1 parent b3aa43d commit 965714e

File tree

6 files changed

+176
-41
lines changed

6 files changed

+176
-41
lines changed

packages/nextjs/src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type NextConfigObject = {
1515
webpack: WebpackConfigFunction;
1616
// whether to build serverless functions for all pages, not just API routes
1717
target: 'server' | 'experimental-serverless-trace';
18+
// the output directory for the built app (defaults to ".next")
19+
distDir: string;
1820
sentry?: {
1921
disableServerWebpackPlugin?: boolean;
2022
disableClientWebpackPlugin?: boolean;

packages/nextjs/src/config/webpack.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { getSentryRelease } from '@sentry/node';
22
import { dropUndefinedKeys, logger } from '@sentry/utils';
33
import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin';
44
import * as fs from 'fs';
5+
import * as os from 'os';
56
import * as path from 'path';
67

78
import {
89
BuildContext,
9-
EntryPointValue,
1010
EntryPropertyObject,
1111
NextConfigObject,
1212
SentryWebpackPluginOptions,
@@ -128,14 +128,31 @@ async function addSentryToEntryProperty(
128128
const newEntryProperty =
129129
typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty };
130130

131+
// `sentry.server.config.js` or `sentry.client.config.js` (or their TS equivalents)
131132
const userConfigFile = buildContext.isServer
132133
? getUserConfigFile(buildContext.dir, 'server')
133134
: getUserConfigFile(buildContext.dir, 'client');
134135

136+
// we need to turn the filename into a path so webpack can find it
137+
const filesToInject = [`./${userConfigFile}`];
138+
139+
// Support non-default output directories by making the output path (easy to get here at build-time) available to the
140+
// server SDK's default `RewriteFrames` instance (which needs it at runtime).
141+
if (buildContext.isServer) {
142+
const rewriteFramesHelper = path.resolve(
143+
fs.mkdtempSync(path.resolve(os.tmpdir(), 'sentry-')),
144+
'rewriteFramesHelper.js',
145+
);
146+
fs.writeFileSync(rewriteFramesHelper, `global.__rewriteFramesDistDir__ = '${buildContext.config.distDir}';\n`);
147+
// stick our helper file ahead of the user's config file so the value is in the global namespace *before*
148+
// `Sentry.init()` is called
149+
filesToInject.unshift(rewriteFramesHelper);
150+
}
151+
152+
// inject into all entry points which might contain user's code
135153
for (const entryPointName in newEntryProperty) {
136154
if (shouldAddSentryToEntryPoint(entryPointName)) {
137-
// we need to turn the filename into a path so webpack can find it
138-
addFileToExistingEntryPoint(newEntryProperty, entryPointName, `./${userConfigFile}`);
155+
addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject);
139156
}
140157
}
141158

@@ -163,51 +180,52 @@ export function getUserConfigFile(projectDir: string, platform: 'server' | 'clie
163180
}
164181

165182
/**
166-
* Add a file to a specific element of the given `entry` webpack config property.
183+
* Add files to a specific element of the given `entry` webpack config property.
167184
*
168185
* @param entryProperty The existing `entry` config object
169186
* @param entryPointName The key where the file should be injected
170-
* @param filepath The path to the injected file
187+
* @param filepaths An array of paths to the injected files
171188
*/
172-
function addFileToExistingEntryPoint(
189+
function addFilesToExistingEntryPoint(
173190
entryProperty: EntryPropertyObject,
174191
entryPointName: string,
175-
filepath: string,
192+
filepaths: string[],
176193
): void {
177194
// can be a string, array of strings, or object whose `import` property is one of those two
178195
const currentEntryPoint = entryProperty[entryPointName];
179-
let newEntryPoint: EntryPointValue;
196+
let newEntryPoint = currentEntryPoint;
180197

181198
if (typeof currentEntryPoint === 'string') {
182-
newEntryPoint = [filepath, currentEntryPoint];
199+
newEntryPoint = [...filepaths, currentEntryPoint];
183200
} else if (Array.isArray(currentEntryPoint)) {
184-
newEntryPoint = [filepath, ...currentEntryPoint];
201+
newEntryPoint = [...filepaths, ...currentEntryPoint];
185202
}
186203
// descriptor object (webpack 5+)
187204
else if (typeof currentEntryPoint === 'object' && 'import' in currentEntryPoint) {
188205
const currentImportValue = currentEntryPoint.import;
189206
let newImportValue;
190207

191208
if (typeof currentImportValue === 'string') {
192-
newImportValue = [filepath, currentImportValue];
209+
newImportValue = [...filepaths, currentImportValue];
193210
} else {
194-
newImportValue = [filepath, ...currentImportValue];
211+
newImportValue = [...filepaths, ...currentImportValue];
195212
}
196213

197214
newEntryPoint = {
198215
...currentEntryPoint,
199216
import: newImportValue,
200217
};
201-
} else {
202-
// mimic the logger prefix in order to use `console.warn` (which will always be printed, regardless of SDK settings)
218+
}
219+
// malformed entry point (use `console.error` rather than `logger.error` because it will always be printed, regardless
220+
// of SDK settings)
221+
else {
203222
// eslint-disable-next-line no-console
204223
console.error(
205224
'Sentry Logger [Error]:',
206-
`Could not inject SDK initialization code into entry point ${entryPointName}, as it is not a recognized format.\n`,
225+
`Could not inject SDK initialization code into entry point ${entryPointName}, as its current value is not in a recognized format.\n`,
207226
`Expected: string | Array<string> | { [key:string]: any, import: string | Array<string> }\n`,
208227
`Got: ${currentEntryPoint}`,
209228
);
210-
return;
211229
}
212230

213231
entryProperty[entryPointName] = newEntryPoint;

packages/nextjs/src/index.server.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { RewriteFrames } from '@sentry/integrations';
22
import { configureScope, getCurrentHub, init as nodeInit, Integrations } from '@sentry/node';
3-
import { logger } from '@sentry/utils';
3+
import { escapeStringForRegex, logger } from '@sentry/utils';
4+
import * as path from 'path';
45

56
import { instrumentServer } from './utils/instrumentServer';
67
import { MetadataBuilder } from './utils/metadataBuilder';
@@ -13,6 +14,8 @@ export * from '@sentry/node';
1314
// because or SSR of next.js we can only use this.
1415
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
1516

17+
type GlobalWithDistDir = typeof global & { __rewriteFramesDistDir__: string };
18+
1619
/** Inits the Sentry NextJS SDK on node. */
1720
export function init(options: NextjsOptions): void {
1821
if (options.debug) {
@@ -29,7 +32,6 @@ export function init(options: NextjsOptions): void {
2932
const metadataBuilder = new MetadataBuilder(options, ['nextjs', 'node']);
3033
metadataBuilder.addSdkMetadata();
3134
options.environment = options.environment || process.env.NODE_ENV;
32-
// TODO capture project root and store in an env var for RewriteFrames?
3335
addServerIntegrations(options);
3436
// Right now we only capture frontend sessions for Next.js
3537
options.autoSessionTracking = false;
@@ -47,25 +49,29 @@ function sdkAlreadyInitialized(): boolean {
4749
return !!hub.getClient();
4850
}
4951

50-
const SOURCEMAP_FILENAME_REGEX = /^.*\/\.next\//;
51-
52-
const defaultRewriteFramesIntegration = new RewriteFrames({
53-
iteratee: frame => {
54-
frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next/');
55-
return frame;
56-
},
57-
});
58-
59-
const defaultHttpTracingIntegration = new Integrations.Http({ tracing: true });
60-
6152
function addServerIntegrations(options: NextjsOptions): void {
53+
// This value is injected at build time, based on the output directory specified in the build config
54+
const distDirName = (global as GlobalWithDistDir).__rewriteFramesDistDir__ || '.next';
55+
// nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so
56+
// we can read in the project directory from the currently running process
57+
const distDirAbsPath = path.resolve(process.cwd(), distDirName);
58+
const SOURCEMAP_FILENAME_REGEX = new RegExp(escapeStringForRegex(distDirAbsPath));
59+
60+
const defaultRewriteFramesIntegration = new RewriteFrames({
61+
iteratee: frame => {
62+
frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next');
63+
return frame;
64+
},
65+
});
66+
6267
if (options.integrations) {
6368
options.integrations = addIntegration(defaultRewriteFramesIntegration, options.integrations);
6469
} else {
6570
options.integrations = [defaultRewriteFramesIntegration];
6671
}
6772

6873
if (options.tracesSampleRate !== undefined || options.tracesSampler !== undefined) {
74+
const defaultHttpTracingIntegration = new Integrations.Http({ tracing: true });
6975
options.integrations = addIntegration(defaultHttpTracingIntegration, options.integrations, {
7076
Http: { keyPath: '_tracing', value: true },
7177
});

packages/nextjs/test/config.test.ts

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ const mockExistsSync = (path: fs.PathLike) => {
3737
};
3838
const exitsSync = jest.spyOn(fs, 'existsSync').mockImplementation(mockExistsSync);
3939

40+
// Make it so that all temporary folders, either created directly by tests or by the code they're testing, will go into
41+
// one spot that we know about, which we can then clean up when we're done
42+
const realTmpdir = jest.requireActual('os').tmpdir;
43+
const TEMP_DIR_PATH = path.join(realTmpdir(), 'sentry-nextjs-test');
44+
jest.spyOn(os, 'tmpdir').mockReturnValue(TEMP_DIR_PATH);
45+
// In theory, we should always land in the `else` here, but this saves the cases where the prior run got interrupted and
46+
// the `afterAll` below didn't happen.
47+
if (fs.existsSync(TEMP_DIR_PATH)) {
48+
rimraf.sync(path.join(TEMP_DIR_PATH, '*'));
49+
} else {
50+
fs.mkdirSync(TEMP_DIR_PATH);
51+
}
52+
53+
afterAll(() => {
54+
rimraf.sync(TEMP_DIR_PATH);
55+
});
56+
57+
// In order to know what to expect in the webpack config `entry` property, we need to know the path of the temporary
58+
// directory created when doing the file injection, so wrap the real `mkdtempSync` and store the resulting path where we
59+
// can access it
60+
const mkdtempSyncSpy = jest.spyOn(fs, 'mkdtempSync');
61+
62+
afterEach(() => {
63+
mkdtempSyncSpy.mockClear();
64+
});
65+
4066
/** Mocks of the arguments passed to `withSentryConfig` */
4167
const userNextConfig: Partial<NextConfigObject> = {
4268
publicRuntimeConfig: { location: 'dogpark', activities: ['fetch', 'chasing', 'digging'] },
@@ -103,7 +129,12 @@ function getBuildContext(
103129
dev: false,
104130
buildId: 'sItStAyLiEdOwN',
105131
dir: '/Users/Maisey/projects/squirrelChasingSimulator',
106-
config: { target: 'server', ...userNextConfig } as NextConfigObject,
132+
config: {
133+
// nextjs's default values
134+
target: 'server',
135+
distDir: '.next',
136+
...userNextConfig,
137+
} as NextConfigObject,
107138
webpack: { version: webpackVersion },
108139
isServer: buildTarget === 'server',
109140
};
@@ -279,26 +310,35 @@ describe('webpack config', () => {
279310
incomingWebpackBuildContext: serverBuildContext,
280311
});
281312

313+
const tempDir = mkdtempSyncSpy.mock.results[0].value;
314+
const rewriteFramesHelper = path.join(tempDir, 'rewriteFramesHelper.js');
315+
282316
expect(finalWebpackConfig.entry).toEqual(
283317
expect.objectContaining({
284318
// original entry point value is a string
285319
// (was 'private-next-pages/api/dogs/[name].js')
286-
'pages/api/dogs/[name]': [serverConfigFilePath, 'private-next-pages/api/dogs/[name].js'],
320+
'pages/api/dogs/[name]': [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/api/dogs/[name].js'],
287321

288322
// original entry point value is a string array
289323
// (was ['./node_modules/smellOVision/index.js', 'private-next-pages/_app.js'])
290-
'pages/_app': [serverConfigFilePath, './node_modules/smellOVision/index.js', 'private-next-pages/_app.js'],
324+
'pages/_app': [
325+
rewriteFramesHelper,
326+
serverConfigFilePath,
327+
'./node_modules/smellOVision/index.js',
328+
'private-next-pages/_app.js',
329+
],
291330

292331
// original entry point value is an object containing a string `import` value
293332
// (`import` was 'private-next-pages/api/simulator/dogStats/[name].js')
294333
'pages/api/simulator/dogStats/[name]': {
295-
import: [serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'],
334+
import: [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'],
296335
},
297336

298337
// original entry point value is an object containing a string array `import` value
299338
// (`import` was ['./node_modules/dogPoints/converter.js', 'private-next-pages/api/simulator/leaderboard.js'])
300339
'pages/api/simulator/leaderboard': {
301340
import: [
341+
rewriteFramesHelper,
302342
serverConfigFilePath,
303343
'./node_modules/dogPoints/converter.js',
304344
'private-next-pages/api/simulator/leaderboard.js',
@@ -308,14 +348,14 @@ describe('webpack config', () => {
308348
// original entry point value is an object containg properties besides `import`
309349
// (`dependOn` remains untouched)
310350
'pages/api/tricks/[trickName]': {
311-
import: [serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'],
351+
import: [rewriteFramesHelper, serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'],
312352
dependOn: 'treats',
313353
},
314354
}),
315355
);
316356
});
317357

318-
it('does not inject into non-_app, non-API routes', async () => {
358+
it('does not inject anything into non-_app, non-API routes', async () => {
319359
const finalWebpackConfig = await materializeFinalWebpackConfig({
320360
userNextConfig,
321361
incomingWebpackConfig: clientWebpackConfig,
@@ -326,12 +366,62 @@ describe('webpack config', () => {
326366
expect.objectContaining({
327367
// no injected file
328368
main: './src/index.ts',
329-
// was 'next-client-pages-loader?page=%2F_app'
369+
}),
370+
);
371+
});
372+
373+
it('does not inject `RewriteFrames` helper into client routes', async () => {
374+
const finalWebpackConfig = await materializeFinalWebpackConfig({
375+
userNextConfig,
376+
incomingWebpackConfig: clientWebpackConfig,
377+
incomingWebpackBuildContext: clientBuildContext,
378+
});
379+
380+
expect(finalWebpackConfig.entry).toEqual(
381+
expect.objectContaining({
382+
// was 'next-client-pages-loader?page=%2F_app', and now has client config but not`RewriteFrames` helper injected
330383
'pages/_app': [clientConfigFilePath, 'next-client-pages-loader?page=%2F_app'],
331384
}),
332385
);
333386
});
334387
});
388+
389+
describe('`distDir` value in default server-side `RewriteFrames` integration', () => {
390+
it.each([
391+
['no custom `distDir`', undefined, '.next'],
392+
['custom `distDir`', 'dist', 'dist'],
393+
])(
394+
'creates file injecting `distDir` value into `global` - %s',
395+
async (_name, customDistDir, expectedInjectedValue) => {
396+
// Note: the fact that the file tested here gets injected correctly is covered in the 'webpack `entry` property
397+
// config' tests above
398+
399+
const userNextConfigDistDir = {
400+
...userNextConfig,
401+
...(customDistDir && { distDir: customDistDir }),
402+
};
403+
await materializeFinalWebpackConfig({
404+
userNextConfig: userNextConfigDistDir,
405+
incomingWebpackConfig: serverWebpackConfig,
406+
incomingWebpackBuildContext: getBuildContext('server', userNextConfigDistDir),
407+
});
408+
409+
const tempDir = mkdtempSyncSpy.mock.results[0].value;
410+
const rewriteFramesHelper = path.join(tempDir, 'rewriteFramesHelper.js');
411+
412+
expect(fs.existsSync(rewriteFramesHelper)).toBe(true);
413+
414+
const injectedCode = fs.readFileSync(rewriteFramesHelper).toString();
415+
expect(injectedCode).toEqual(`global.__rewriteFramesDistDir__ = '${expectedInjectedValue}';\n`);
416+
},
417+
);
418+
419+
describe('`RewriteFrames` ends up with correct `distDir` value', () => {
420+
// TODO: this, along with any number of other parts of the build process, should be tested with an integration
421+
// test which actually runs webpack and inspects the resulting bundles (and that integration test should test
422+
// custom `distDir` values with and without a `.`, to make sure the regex escaping is working)
423+
});
424+
});
335425
});
336426

337427
describe('Sentry webpack plugin config', () => {
@@ -587,12 +677,11 @@ describe('Sentry webpack plugin config', () => {
587677
});
588678

589679
beforeEach(() => {
680+
// these will get cleaned up by the file's overall `afterAll` function, and the `mkdtempSync` mock above ensures
681+
// that the location of the created folder is stored in `tempDir`
590682
const tempDirPathPrefix = path.join(os.tmpdir(), 'sentry-nextjs-test-');
591-
tempDir = fs.mkdtempSync(tempDirPathPrefix);
592-
});
593-
594-
afterEach(() => {
595-
rimraf.sync(tempDir);
683+
fs.mkdtempSync(tempDirPathPrefix);
684+
tempDir = mkdtempSyncSpy.mock.results[0].value;
596685
});
597686

598687
afterAll(() => {

packages/nextjs/test/index.server.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const { Integrations } = SentryNode;
1010

1111
const global = getGlobalObject();
1212

13+
// normally this is set as part of the build process, so mock it here
14+
(global as typeof global & { __rewriteFramesDistDir__: string }).__rewriteFramesDistDir__ = '.next';
15+
1316
let configureScopeCallback: (scope: Scope) => void = () => undefined;
1417
jest.spyOn(SentryNode, 'configureScope').mockImplementation(callback => (configureScopeCallback = callback));
1518
const nodeInit = jest.spyOn(SentryNode, 'init');

0 commit comments

Comments
 (0)