Skip to content

Commit 594e5ca

Browse files
authored
feat(nextjs): Add server-side request transactions (#3533)
1 parent 0c4fdf6 commit 594e5ca

File tree

17 files changed

+352
-57
lines changed

17 files changed

+352
-57
lines changed

packages/nextjs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@sentry/integrations": "6.3.6",
2222
"@sentry/node": "6.3.6",
2323
"@sentry/react": "6.3.6",
24+
"@sentry/tracing": "6.3.6",
2425
"@sentry/utils": "6.3.6",
2526
"@sentry/webpack-plugin": "1.15.0",
2627
"tslib": "^1.9.3"

packages/nextjs/src/utils/config.ts

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,46 @@ import { getSentryRelease } from '@sentry/node';
22
import { logger } from '@sentry/utils';
33
import defaultWebpackPlugin, { SentryCliPluginOptions } from '@sentry/webpack-plugin';
44
import * as SentryWebpackPlugin from '@sentry/webpack-plugin';
5+
import * as fs from 'fs';
6+
import * as path from 'path';
7+
8+
const SENTRY_CLIENT_CONFIG_FILE = './sentry.client.config.js';
9+
const SENTRY_SERVER_CONFIG_FILE = './sentry.server.config.js';
10+
// this is where the transpiled/bundled version of `USER_SERVER_CONFIG_FILE` will end up
11+
export const SERVER_SDK_INIT_PATH = 'sentry/initServerSDK.js';
512

613
// eslint-disable-next-line @typescript-eslint/no-explicit-any
714
type PlainObject<T = any> = { [key: string]: T };
815

9-
// Man are these types hard to name well. "Entry" = an item in some collection of items, but in our case, one of the
10-
// things we're worried about here is property (entry) in an object called... entry. So henceforth, the specific
11-
// property we're modifying is going to be known as an EntryProperty.
12-
1316
// The function which is ultimately going to be exported from `next.config.js` under the name `webpack`
1417
type WebpackExport = (config: WebpackConfig, options: WebpackOptions) => WebpackConfig;
1518

1619
// The two arguments passed to the exported `webpack` function, as well as the thing it returns
17-
type WebpackConfig = { devtool: string; plugins: PlainObject[]; entry: EntryProperty };
20+
type WebpackConfig = {
21+
devtool: string;
22+
plugins: PlainObject[];
23+
entry: EntryProperty;
24+
output: { path: string };
25+
target: string;
26+
context: string;
27+
};
1828
type WebpackOptions = { dev: boolean; isServer: boolean; buildId: string };
1929

2030
// For our purposes, the value for `entry` is either an object, or a function which returns such an object
2131
type EntryProperty = (() => Promise<EntryPropertyObject>) | EntryPropertyObject;
22-
2332
// Each value in that object is either a string representing a single entry point, an array of such strings, or an
2433
// object containing either of those, along with other configuration options. In that third case, the entry point(s) are
2534
// listed under the key `import`.
26-
type EntryPropertyObject = PlainObject<string | Array<string> | EntryPointObject>;
35+
type EntryPropertyObject = PlainObject<string> | PlainObject<Array<string>> | PlainObject<EntryPointObject>;
2736
type EntryPointObject = { import: string | Array<string> };
2837

29-
const sentryClientConfig = './sentry.client.config.js';
30-
const sentryServerConfig = './sentry.server.config.js';
31-
32-
/** Add a file (`injectee`) to a given element (`injectionPoint`) of the `entry` property */
38+
/**
39+
* Add a file to a specific element of the given `entry` webpack config property.
40+
*
41+
* @param entryProperty The existing `entry` config object
42+
* @param injectionPoint The key where the file should be injected
43+
* @param injectee The path to the injected file
44+
*/
3345
const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string, injectee: string): void => {
3446
// can be a string, array of strings, or object whose `import` property is one of those two
3547
let injectedInto = entryProperty[injectionPoint];
@@ -41,21 +53,19 @@ const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string,
4153
return;
4254
}
4355

44-
// In case we inject our client config, we need to add it after the frontend code
45-
// otherwise the runtime config isn't loaded. See: https://github.com/getsentry/sentry-javascript/issues/3485
46-
const isClient = injectee === sentryClientConfig;
47-
56+
// We inject the user's client config file after the existing code so that the config file has access to
57+
// `publicRuntimeConfig`. See https://github.com/getsentry/sentry-javascript/issues/3485
4858
if (typeof injectedInto === 'string') {
49-
injectedInto = isClient ? [injectedInto, injectee] : [injectee, injectedInto];
59+
injectedInto = [injectedInto, injectee];
5060
} else if (Array.isArray(injectedInto)) {
51-
injectedInto = isClient ? [...injectedInto, injectee] : [injectee, ...injectedInto];
61+
injectedInto = [...injectedInto, injectee];
5262
} else {
53-
let importVal: string | string[] | EntryPointObject;
63+
let importVal: string | string[];
64+
5465
if (typeof injectedInto.import === 'string') {
55-
importVal = isClient ? [injectedInto.import, injectee] : [injectee, injectedInto.import];
66+
importVal = [injectedInto.import, injectee];
5667
} else {
57-
// If it's not a string, the inner value is an array
58-
importVal = isClient ? [...injectedInto.import, injectee] : [injectee, ...injectedInto.import];
68+
importVal = [...injectedInto.import, injectee];
5969
}
6070

6171
injectedInto = {
@@ -68,8 +78,6 @@ const _injectFile = (entryProperty: EntryPropertyObject, injectionPoint: string,
6878
};
6979

7080
const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean): Promise<EntryProperty> => {
71-
// Out of the box, nextjs uses the `() => Promise<EntryPropertyObject>)` flavor of EntryProperty, where the returned
72-
// object has string arrays for values.
7381
// The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs
7482
// sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether
7583
// someone else has come along before us and changed that, we need to check a few things along the way. The one thing
@@ -80,19 +88,19 @@ const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean)
8088
newEntryProperty = await origEntryProperty();
8189
}
8290
newEntryProperty = newEntryProperty as EntryPropertyObject;
83-
// On the server, we need to inject the SDK into both into the base page (`_document`) and into individual API routes
84-
// (which have no common base).
91+
// Add a new element to the `entry` array, we force webpack to create a bundle out of the user's
92+
// `sentry.server.config.js` file and output it to `SERVER_INIT_LOCATION`. (See
93+
// https://webpack.js.org/guides/code-splitting/#entry-points.) We do this so that the user's config file is run
94+
// through babel (and any other processors through which next runs the rest of the user-provided code - pages, API
95+
// routes, etc.). Specifically, we need any ESM-style `import` code to get transpiled into ES5, so that we can call
96+
// `require()` on the resulting file when we're instrumenting the sesrver. (We can't use a dynamic import there
97+
// because that then forces the user into a particular TS config.)
8598
if (isServer) {
86-
Object.keys(newEntryProperty).forEach(key => {
87-
if (key === 'pages/_document' || key.includes('pages/api')) {
88-
// for some reason, because we're now in a function, we have to cast again
89-
_injectFile(newEntryProperty as EntryPropertyObject, key, sentryServerConfig);
90-
}
91-
});
99+
newEntryProperty[SERVER_SDK_INIT_PATH] = SENTRY_SERVER_CONFIG_FILE;
92100
}
93101
// On the client, it's sufficient to inject it into the `main` JS code, which is included in every browser page.
94102
else {
95-
_injectFile(newEntryProperty, 'main', sentryClientConfig);
103+
_injectFile(newEntryProperty, 'main', SENTRY_CLIENT_CONFIG_FILE);
96104
}
97105
// TODO: hack made necessary because the async-ness of this function turns our object back into a promise, meaning the
98106
// internal `next` code which should do this doesn't
@@ -109,11 +117,18 @@ type NextConfigExports = {
109117
webpack?: WebpackExport;
110118
};
111119

120+
/**
121+
* Add Sentry options to the config to be exported from the user's `next.config.js` file.
122+
*
123+
* @param providedExports The existing config to be exported ,prior to adding Sentry
124+
* @param providedSentryWebpackPluginOptions Configuration for SentryWebpackPlugin
125+
* @returns The modified config to be exported
126+
*/
112127
export function withSentryConfig(
113128
providedExports: NextConfigExports = {},
114-
providedWebpackPluginOptions: Partial<SentryCliPluginOptions> = {},
129+
providedSentryWebpackPluginOptions: Partial<SentryCliPluginOptions> = {},
115130
): NextConfigExports {
116-
const defaultWebpackPluginOptions = {
131+
const defaultSentryWebpackPluginOptions = {
117132
url: process.env.SENTRY_URL,
118133
org: process.env.SENTRY_ORG,
119134
project: process.env.SENTRY_PROJECT,
@@ -122,22 +137,30 @@ export function withSentryConfig(
122137
stripPrefix: ['webpack://_N_E/'],
123138
urlPrefix: `~/_next`,
124139
include: '.next/',
125-
ignore: ['node_modules', 'webpack.config.js'],
140+
ignore: ['.next/cache', 'server/ssr-module-cache.js', 'static/*/_ssgManifest.js', 'static/*/_buildManifest.js'],
126141
};
127142

128143
// warn if any of the default options for the webpack plugin are getting overridden
129-
const webpackPluginOptionOverrides = Object.keys(defaultWebpackPluginOptions)
144+
const sentryWebpackPluginOptionOverrides = Object.keys(defaultSentryWebpackPluginOptions)
130145
.concat('dryrun')
131-
.filter(key => key in Object.keys(providedWebpackPluginOptions));
132-
if (webpackPluginOptionOverrides.length > 0) {
146+
.filter(key => key in Object.keys(providedSentryWebpackPluginOptions));
147+
if (sentryWebpackPluginOptionOverrides.length > 0) {
133148
logger.warn(
134149
'[Sentry] You are overriding the following automatically-set SentryWebpackPlugin config options:\n' +
135-
`\t${webpackPluginOptionOverrides.toString()},\n` +
150+
`\t${sentryWebpackPluginOptionOverrides.toString()},\n` +
136151
"which has the possibility of breaking source map upload and application. This is only a good idea if you know what you're doing.",
137152
);
138153
}
139154

140155
const newWebpackExport = (config: WebpackConfig, options: WebpackOptions): WebpackConfig => {
156+
// if we're building server code, store the webpack output path as an env variable, so we know where to look for the
157+
// webpack-processed version of `sentry.server.config.js` when we need it
158+
if (config.target === 'node') {
159+
const serverSDKInitOutputPath = path.join(config.output.path, SERVER_SDK_INIT_PATH);
160+
const projectDir = config.context;
161+
setRuntimeEnvVars(projectDir, { SENTRY_SERVER_INIT_PATH: serverSDKInitOutputPath });
162+
}
163+
141164
let newConfig = config;
142165

143166
if (typeof providedExports.webpack === 'function') {
@@ -161,8 +184,8 @@ export function withSentryConfig(
161184
new ((SentryWebpackPlugin as unknown) as typeof defaultWebpackPlugin)({
162185
dryRun: options.dev,
163186
release: getSentryRelease(options.buildId),
164-
...defaultWebpackPluginOptions,
165-
...providedWebpackPluginOptions,
187+
...defaultSentryWebpackPluginOptions,
188+
...providedSentryWebpackPluginOptions,
166189
}),
167190
);
168191

@@ -175,3 +198,39 @@ export function withSentryConfig(
175198
webpack: newWebpackExport,
176199
};
177200
}
201+
202+
/**
203+
* Set variables to be added to the env at runtime, by storing them in `.env.local` (which `next` automatically reads
204+
* into memory at server startup).
205+
*
206+
* @param projectDir The path to the project root
207+
* @param vars Object containing vars to set
208+
*/
209+
function setRuntimeEnvVars(projectDir: string, vars: PlainObject<string>): void {
210+
// ensure the file exists
211+
const envFilePath = path.join(projectDir, '.env.local');
212+
if (!fs.existsSync(envFilePath)) {
213+
fs.writeFileSync(envFilePath, '');
214+
}
215+
216+
let fileContents = fs
217+
.readFileSync(envFilePath)
218+
.toString()
219+
.trim();
220+
221+
Object.entries(vars).forEach(entry => {
222+
const [varName, value] = entry;
223+
const envVarString = `${varName}=${value}`;
224+
225+
// new entry
226+
if (!fileContents.includes(varName)) {
227+
fileContents = `${fileContents}\n${envVarString}`;
228+
}
229+
// existing entry; make sure value is up to date
230+
else {
231+
fileContents = fileContents.replace(new RegExp(`${varName}=\\S+`), envVarString);
232+
}
233+
});
234+
235+
fs.writeFileSync(envFilePath, `${fileContents.trim()}\n`);
236+
}

0 commit comments

Comments
 (0)