Skip to content

feat(nextjs): Inject user sentry config files at app startup via webpack #3463

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@sentry/types": "6.3.1",
"@types/webpack": "^5.28.0",
"eslint": "7.20.0",
"next": "^10.1.3",
"rimraf": "3.0.2"
},
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export function init(options: NextjsOptions): void {
}

export { withSentryConfig } from './utils/config';
export { withSentry } from './utils/handlers';
147 changes: 121 additions & 26 deletions packages/nextjs/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,93 @@ import { logger } from '@sentry/utils';
import defaultWebpackPlugin, { SentryCliPluginOptions } from '@sentry/webpack-plugin';
import * as SentryWebpackPlugin from '@sentry/webpack-plugin';

type WebpackConfig = { devtool: string; plugins: Array<{ [key: string]: any }> };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PlainObject<T = any> = { [key: string]: T };

// Man are these types hard to name well. "Entry" = an item in some collection of items, but in our case, one of the
// things we're worried about here is property (entry) in an object called... entry. So henceforth, the specific
// proptery we're modifying is going to be known as an EntryProperty, or EP for short.

// The function which is ultimately going to be exported from `next.config.js` under the name `webpack`
type WebpackExport = (config: WebpackConfig, options: WebpackOptions) => WebpackConfig;
// type WebpackExport = (config: WebpackConfig, options: WebpackOptions) => Promise<WebpackConfig>;

// The two arguments passed to the exported `webpack` function, as well as the thing it returns
type WebpackConfig = { devtool: string; plugins: PlainObject[]; entry: EntryProperty };
type WebpackOptions = { dev: boolean; isServer: boolean };

// For our purposes, the value for `entry` is either an object, or a function which returns such an object
type EntryProperty = (() => Promise<EntryPropertyObject>) | EntryPropertyObject;

// Each value in that object is either a string representing a single entry point, an array of such strings, or an
// object containing either of those, along with other configuration options. In that third case, the entry point(s) are
// listed under the key `import`.
type EntryPropertyObject = PlainObject<string | Array<string> | EntryPointObject>;
type EntryPointObject = { import: string | Array<string> };

// const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean): Promise<EntryPropertyObject> => {
const injectSentry = async (origEntryProperty: EntryProperty, isServer: boolean): Promise<EntryProperty> => {
// Out of the box, nextjs uses the `() => Promise<EntryPropertyObject>)` flavor of EntryProperty, where the returned
// object has string arrays for values. But because we don't know whether someone else has come along before us and
// changed that, we need to check a few things along the way.

// The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs
// sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether
// someone else has come along before us and changed that, we need to check a few things along the way. The one thing
// we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function
// options. See https://webpack.js.org/configuration/entry-context/#entry.

let newEntryProperty = origEntryProperty;

if (typeof origEntryProperty === 'function') {
newEntryProperty = await origEntryProperty();
}

newEntryProperty = newEntryProperty as EntryPropertyObject;

// according to vercel, we only need to inject Sentry in one spot for server and one spot for client, and because
// those are used as bases, it will apply everywhere
const injectionPoint = isServer ? 'pages/_document' : 'main';
const injectee = isServer ? './sentry.server.config.js' : './sentry.client.config.js';

// can be a string, array of strings, or object whose `import` property is one of those two
let injectedInto = newEntryProperty[injectionPoint];

// whatever the format, add in the sentry file
injectedInto =
typeof injectedInto === 'string'
? // string case
[injectee, injectedInto]
: // not a string, must be an array or object
Array.isArray(injectedInto)
? // array case
[injectee, ...injectedInto]
: // object case
{
...injectedInto,
import:
typeof injectedInto.import === 'string'
? // string case for inner property
[injectee, injectedInto.import]
: // array case for inner property
[injectee, ...injectedInto.import],
};

newEntryProperty[injectionPoint] = injectedInto;

// TODO: hack made necessary because promises are currently kicking my butt
if ('main.js' in newEntryProperty) {
delete newEntryProperty['main.js'];
}

return newEntryProperty;
};

type NextConfigExports = {
experimental?: { plugins: boolean };
plugins?: string[];
productionBrowserSourceMaps?: boolean;
webpack?: (config: WebpackConfig, { dev }: { dev: boolean }) => WebpackConfig;
webpack?: WebpackExport;
};

export function withSentryConfig(
Expand All @@ -27,6 +108,8 @@ export function withSentryConfig(
include: '.next/',
ignore: ['node_modules', 'webpack.config.js'],
};

// warn if any of the default options for the webpack plugin are getting overridden
const webpackPluginOptionOverrides = Object.keys(defaultWebpackPluginOptions)
.concat('dryrun')
.map(key => key in Object.keys(providedWebpackPluginOptions));
Expand All @@ -38,32 +121,44 @@ export function withSentryConfig(
);
}

// const newWebpackExport = async (config: WebpackConfig, options: WebpackOptions): Promise<WebpackConfig> => {
const newWebpackExport = (config: WebpackConfig, options: WebpackOptions): WebpackConfig => {
let newConfig = config;

if (typeof providedExports.webpack === 'function') {
newConfig = providedExports.webpack(config, options);
// newConfig = await providedExports.webpack(config, options);
}

// Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let you
// change this is dev even if you want to - see
// https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md.)
if (!options.dev) {
newConfig.devtool = 'source-map';
}

// Inject user config files (`sentry.client.confg.js` and `sentry.server.config.js`), which is where `Sentry.init()`
// is called. By adding them here, we ensure that they're bundled by webpack as part of both server code and client code.
newConfig.entry = (injectSentry(newConfig.entry, options.isServer) as unknown) as EntryProperty;
// newConfig.entry = await injectSentry(newConfig.entry, options.isServer);
// newConfig.entry = async () => injectSentry(newConfig.entry, options.isServer);

// Add the Sentry plugin, which uploads source maps to Sentry when not in dev
newConfig.plugins.push(
// TODO it's not clear how to do this better, but there *must* be a better way
new ((SentryWebpackPlugin as unknown) as typeof defaultWebpackPlugin)({
dryRun: options.dev,
...defaultWebpackPluginOptions,
...providedWebpackPluginOptions,
}),
);

return newConfig;
};

return {
...providedExports,
productionBrowserSourceMaps: true,
webpack: (originalConfig, options) => {
let config = originalConfig;

if (typeof providedExports.webpack === 'function') {
config = providedExports.webpack(originalConfig, options);
}

if (!options.dev) {
// Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let
// you change this is dev even if you want to - see
// https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md.)
config.devtool = 'source-map';
}
config.plugins.push(
// TODO it's not clear how to do this better, but there *must* be a better way
new ((SentryWebpackPlugin as unknown) as typeof defaultWebpackPlugin)({
dryRun: options.dev,
...defaultWebpackPluginOptions,
...providedWebpackPluginOptions,
}),
);

return config;
},
webpack: newWebpackExport,
};
}
19 changes: 19 additions & 0 deletions packages/nextjs/src/utils/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { captureException, flush } from '@sentry/node';
import { NextApiRequest, NextApiResponse } from 'next';

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const withSentry = (handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void>) => {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
// TODO: Start Transaction
// TODO: Extract data from req
return await handler(req, res); // Call Handler
// TODO: Finish Transaction
} catch (e) {
captureException(e);
await flush(2000);
throw e;
}
};
};
Loading