Skip to content

Commit 3f5c9c4

Browse files
authored
feat(nextjs): Support assetPrefix option (#6388)
This adds support support for the nextjs `assetPrefix` option to the nextjs SDK. Currently, if users set this option, it changes the filepaths in their client-side stacktraces in a way not reflected in their release artifact names, causing sourcemaps not to work. This fixes that by using `RewriteFrames` to modify the stacktrace filepaths so that they'll match the release artifacts. Notes: - Initial work on this was done by @tomas-c in #6241. (Thanks, @tomas-c!) The approach taken there was to change the way the client-side release artifacts are named, rather than to change the filenames in the stacktrace as is done here. After discussions with @lforst, I decided to take this approach because it's more consistent with what we already do on the server side. There, we use `RewriteFrames`, and we mutate the filepaths to start with `app:///_next`. This mirrors that for the client side. - In the process of working on this, I discovered that we currently have a bug, caused by the way we handle the `basePath` option when naming release artifacts, including it at the beginning of all artifact names. Unfortunately, it's not included in stacktrace filenames, so this is a mistake. On the server side, this makes the stacktrace filenames and the artifact filenames not match, meaning sourcemaps for people using that option are currently broken for all sever-side errors. (The reason this hasn't been more obvious is that in the node SDK, we harvest context lines at capture time, rather than relying on sourcemapping to provide them. Also, server-side built code is transpiled but not bundled or mangled, making even un-sourcemapped code much easier to read than it is on the browser side of things.) On the browser side, it doesn't break sourcemaps, but only because it turns out that nextjs copies `basePath` over into `assetPrefix` if a user provides the former but not the latter. As mentioned, `basePath` doesn't appear in stacktrace filepaths, but `assetPrefix` does, which is what led us to think was working. To fix this, this PR stops including `basePath` in artifact filenames. As a result, on both the server-side and client-side, all artifact filenames are now of the form `~/_next/...`, rather than some looking like that but others looking like `~/basePathValue/_next/...`. - Part of the work here was figuring out how `distDir`, `basePath`, and `assetPrefix` interact, and where they show up in stacktrace filepaths. Just so it's written down somewhere, the answer is: - `basePath` - Never shows up in stacktrace filepaths, except insofar as it's copied into `assetPrefix` if it's set and `assetPrefix` isn't, in which case `assetPrefix` acts like it's path-only. - `distDir` - Server-side stacktrace filepaths are of the form `<pathToProjectDir>/<distDir>/server/...` (or `<pathToProjectDir>/<distDir>/serverless/...`). Example: `/path/on/host/machine/someDistDir/server/...`. - `assetPrefix` - If `assetPrefix` is a full url, client-side stacktrace filepaths are of the form `<assetPrefixHost>/<assetPrefixPath>/_next/static/...`. If `assetPrefix` just a path, stacktrace filepaths are of the form `<host>/<assetPrefixPath>/_next/static/...`. Examples: http://some.assetPrefix.host/some/assetPrefix/path/_next/...` and `http://some.normal.domain/some/assetPrefix/path/_next/...`. Fixes #4174.
1 parent 3f14a57 commit 3f5c9c4

File tree

8 files changed

+67
-113
lines changed

8 files changed

+67
-113
lines changed

packages/nextjs/rollup.npm.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default [
2121
makeBaseNPMConfig({
2222
entrypoints: [
2323
'src/config/templates/serverRewriteFramesPrefixLoaderTemplate.ts',
24+
'src/config/templates/clientRewriteFramesPrefixLoaderTemplate.ts',
2425
'src/config/templates/pageProxyLoaderTemplate.ts',
2526
'src/config/templates/apiProxyLoaderTemplate.ts',
2627
],
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* eslint-disable no-restricted-globals */
2+
/* eslint-disable @typescript-eslint/no-explicit-any */
3+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
4+
5+
(window as any).__rewriteFramesAssetPrefixPath__ = '__ASSET_PREFIX_PATH__';
6+
7+
// We need this to make this file an ESM module, which TS requires when using `isolatedModules`, but it doesn't affect
8+
// the end result - Rollup recognizes that it's a no-op and doesn't include it when building our code.
9+
export {};

packages/nextjs/src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type NextConfigObject = {
3333
target?: 'server' | 'experimental-serverless-trace';
3434
// The output directory for the built app (defaults to ".next")
3535
distDir?: string;
36+
// URL location of `_next/static` directory when hosted on a CDN
37+
assetPrefix?: string;
3638
// The root at which the nextjs app will be served (defaults to "/")
3739
basePath?: string;
3840
// Config which will be available at runtime

packages/nextjs/src/config/webpack.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable complexity */
12
/* eslint-disable max-lines */
23
import { getSentryRelease } from '@sentry/node';
34
import {
@@ -80,10 +81,10 @@ export function constructWebpackConfigFunction(
8081
// `newConfig.module.rules` is required, so we don't have to keep asserting its existence
8182
const newConfig = setUpModuleRules(rawNewConfig);
8283

83-
if (isServer) {
84-
// This loader will inject code setting global values for use by `RewriteFrames`
85-
addRewriteFramesLoader(newConfig, 'server', userNextConfig);
84+
// Add a loader which will inject code that sets global values for use by `RewriteFrames`
85+
addRewriteFramesLoader(newConfig, isServer ? 'server' : 'client', userNextConfig);
8686

87+
if (isServer) {
8788
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
8889
const pagesDir = newConfig.resolve?.alias?.['private-next-pages'] as string;
8990

@@ -498,7 +499,7 @@ export function getWebpackPluginOptions(
498499
const isWebpack5 = webpack.version.startsWith('5');
499500
const isServerless = userNextConfig.target === 'experimental-serverless-trace';
500501
const hasSentryProperties = fs.existsSync(path.resolve(projectDir, 'sentry.properties'));
501-
const urlPrefix = userNextConfig.basePath ? `~${userNextConfig.basePath}/_next` : '~/_next';
502+
const urlPrefix = '~/_next';
502503

503504
const serverInclude = isServerless
504505
? [{ paths: [`${distDirAbsPath}/serverless/`], urlPrefix: `${urlPrefix}/serverless` }]
@@ -645,8 +646,8 @@ function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWi
645646
}
646647

647648
/**
648-
* Support the `distDir` option by making its value (easy to get here at build-time) available to the server SDK's
649-
* default `RewriteFrames` instance (which needs it at runtime), by injecting code to attach it to `global`.
649+
* Support the `distDir` and `assetPrefix` options by making their values (easy to get here at build-time) available at
650+
* runtime (for use by `RewriteFrames`), by injecting code to attach their values to `global` or `window`.
650651
*
651652
* @param newConfig The webpack config object being constructed
652653
* @param target Either 'server' or 'client'
@@ -666,6 +667,16 @@ function addRewriteFramesLoader(
666667
userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next',
667668
],
668669
],
670+
client: [
671+
[
672+
'__ASSET_PREFIX_PATH__',
673+
// Get the path part of `assetPrefix`, minus any trailing slash. (We use a placeholder for the origin if
674+
// `assetPreix` doesn't include one. Since we only care about the path, it doesn't matter what it is.)
675+
userNextConfig.assetPrefix
676+
? new URL(userNextConfig.assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '')
677+
: '',
678+
],
679+
],
669680
};
670681

671682
newConfig.module.rules.push({
@@ -675,8 +686,7 @@ function addRewriteFramesLoader(
675686
loader: path.resolve(__dirname, 'loaders/prefixLoader.js'),
676687
options: {
677688
templatePrefix: `${target}RewriteFrames`,
678-
// This weird cast will go away as soon as we add the client half of this function in
679-
replacements: replacements[target as 'server'],
689+
replacements: replacements[target],
680690
},
681691
},
682692
],

packages/nextjs/src/index.client.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RewriteFrames } from '@sentry/integrations';
12
import { configureScope, init as reactInit, Integrations } from '@sentry/react';
23
import { BrowserTracing, defaultRequestInstrumentationOptions, hasTracingEnabled } from '@sentry/tracing';
34
import { EventProcessor } from '@sentry/types';
@@ -28,6 +29,8 @@ export { BrowserTracing };
2829
// Treeshakable guard to remove all code related to tracing
2930
declare const __SENTRY_TRACING__: boolean;
3031

32+
type GlobalWithAssetPrefixPath = typeof global & { __rewriteFramesAssetPrefixPath__: string };
33+
3134
/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
3235
export function init(options: NextjsOptions): void {
3336
buildMetadata(options, ['nextjs', 'react']);
@@ -48,6 +51,25 @@ export function init(options: NextjsOptions): void {
4851
function addClientIntegrations(options: NextjsOptions): void {
4952
let integrations = options.integrations || [];
5053

54+
// This value is injected at build time, based on the output directory specified in the build config. Though a default
55+
// is set there, we set it here as well, just in case something has gone wrong with the injection.
56+
const assetPrefixPath = (global as GlobalWithAssetPrefixPath).__rewriteFramesAssetPrefixPath__ || '';
57+
58+
const defaultRewriteFramesIntegration = new RewriteFrames({
59+
// Turn `<origin>/<path>/_next/static/...` into `app:///_next/static/...`
60+
iteratee: frame => {
61+
try {
62+
const { origin } = new URL(frame.filename as string);
63+
frame.filename = frame.filename?.replace(origin, 'app://').replace(assetPrefixPath, '');
64+
} catch (err) {
65+
// Filename wasn't a properly formed URL, so there's nothing we can do
66+
}
67+
68+
return frame;
69+
},
70+
});
71+
integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations);
72+
5173
// This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside
5274
// will get treeshaken away
5375
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {

packages/nextjs/test/config/loaders.test.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,23 +60,22 @@ describe('webpack loaders', () => {
6060
});
6161

6262
describe('client loaders', () => {
63-
it("doesn't add `RewriteFrames` loader to client config", async () => {
63+
it('adds `RewriteFrames` loader to client config', async () => {
6464
const finalWebpackConfig = await materializeFinalWebpackConfig({
6565
exportedNextConfig,
6666
incomingWebpackConfig: clientWebpackConfig,
6767
incomingWebpackBuildContext: clientBuildContext,
6868
});
6969

70-
expect(finalWebpackConfig.module.rules).not.toContainEqual(
71-
expect.objectContaining({
72-
use: [
73-
{
74-
loader: expect.stringEndingWith('prefixLoader.js'),
75-
options: expect.objectContaining({ templatePrefix: expect.stringContaining('RewriteFrames') }),
76-
},
77-
],
78-
}),
79-
);
70+
expect(finalWebpackConfig.module.rules).toContainEqual({
71+
test: /sentry\.client\.config\.(jsx?|tsx?)/,
72+
use: [
73+
{
74+
loader: expect.stringEndingWith('prefixLoader.js'),
75+
options: expect.objectContaining({ templatePrefix: 'clientRewriteFrames' }),
76+
},
77+
],
78+
});
8079
});
8180
});
8281
});

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

Lines changed: 0 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -202,99 +202,6 @@ describe('Sentry webpack plugin config', () => {
202202
});
203203
});
204204

205-
describe("Sentry webpack plugin `include` option with basePath filled on next's config", () => {
206-
const exportedNextConfigWithBasePath = {
207-
...exportedNextConfig,
208-
basePath: '/city-park',
209-
};
210-
211-
it('has the correct value when building client bundles', async () => {
212-
const buildContext = getBuildContext('client', exportedNextConfigWithBasePath);
213-
const finalWebpackConfig = await materializeFinalWebpackConfig({
214-
exportedNextConfig: exportedNextConfigWithBasePath,
215-
incomingWebpackConfig: clientWebpackConfig,
216-
incomingWebpackBuildContext: buildContext,
217-
});
218-
219-
const sentryWebpackPluginInstance = findWebpackPlugin(
220-
finalWebpackConfig,
221-
'SentryCliPlugin',
222-
) as SentryWebpackPlugin;
223-
224-
expect(sentryWebpackPluginInstance.options.include).toEqual([
225-
{
226-
paths: [`${buildContext.dir}/.next/static/chunks/pages`],
227-
urlPrefix: '~/city-park/_next/static/chunks/pages',
228-
},
229-
]);
230-
});
231-
232-
it('has the correct value when building serverless server bundles', async () => {
233-
const exportedNextConfigServerless = {
234-
...exportedNextConfigWithBasePath,
235-
target: 'experimental-serverless-trace' as const,
236-
};
237-
const buildContext = getBuildContext('server', exportedNextConfigServerless);
238-
239-
const finalWebpackConfig = await materializeFinalWebpackConfig({
240-
exportedNextConfig: exportedNextConfigServerless,
241-
incomingWebpackConfig: serverWebpackConfig,
242-
incomingWebpackBuildContext: buildContext,
243-
});
244-
245-
const sentryWebpackPluginInstance = findWebpackPlugin(
246-
finalWebpackConfig,
247-
'SentryCliPlugin',
248-
) as SentryWebpackPlugin;
249-
250-
expect(sentryWebpackPluginInstance.options.include).toEqual([
251-
{ paths: [`${buildContext.dir}/.next/serverless/`], urlPrefix: '~/city-park/_next/serverless' },
252-
]);
253-
});
254-
255-
it('has the correct value when building serverful server bundles using webpack 4', async () => {
256-
const serverBuildContextWebpack4 = getBuildContext('server', exportedNextConfigWithBasePath);
257-
serverBuildContextWebpack4.webpack.version = '4.15.13';
258-
259-
const finalWebpackConfig = await materializeFinalWebpackConfig({
260-
exportedNextConfig: exportedNextConfigWithBasePath,
261-
incomingWebpackConfig: serverWebpackConfig,
262-
incomingWebpackBuildContext: serverBuildContextWebpack4,
263-
});
264-
265-
const sentryWebpackPluginInstance = findWebpackPlugin(
266-
finalWebpackConfig,
267-
'SentryCliPlugin',
268-
) as SentryWebpackPlugin;
269-
270-
expect(sentryWebpackPluginInstance.options.include).toEqual([
271-
{
272-
paths: [`${serverBuildContextWebpack4.dir}/.next/server/pages/`],
273-
urlPrefix: '~/city-park/_next/server/pages',
274-
},
275-
]);
276-
});
277-
278-
it('has the correct value when building serverful server bundles using webpack 5', async () => {
279-
const buildContext = getBuildContext('server', exportedNextConfigWithBasePath);
280-
const finalWebpackConfig = await materializeFinalWebpackConfig({
281-
exportedNextConfig: exportedNextConfigWithBasePath,
282-
incomingWebpackConfig: serverWebpackConfig,
283-
incomingWebpackBuildContext: buildContext,
284-
});
285-
286-
const sentryWebpackPluginInstance = findWebpackPlugin(
287-
finalWebpackConfig,
288-
'SentryCliPlugin',
289-
) as SentryWebpackPlugin;
290-
291-
expect(sentryWebpackPluginInstance.options.include).toEqual([
292-
{ paths: [`${buildContext.dir}/.next/server/pages/`], urlPrefix: '~/city-park/_next/server/pages' },
293-
{ paths: [`${buildContext.dir}/.next/server/chunks/`], urlPrefix: '~/city-park/_next/server/chunks' },
294-
]);
295-
});
296-
});
297-
298205
describe('SentryWebpackPlugin enablement', () => {
299206
let processEnvBackup: typeof process.env;
300207

packages/nextjs/test/index.client.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ describe('Client init()', () => {
6363
},
6464
},
6565
environment: 'test',
66-
integrations: [],
66+
integrations: expect.arrayContaining([
67+
expect.objectContaining({
68+
name: 'RewriteFrames',
69+
}),
70+
]),
6771
}),
6872
);
6973
});

0 commit comments

Comments
 (0)