diff --git a/MIGRATION.md b/MIGRATION.md index b1e951a63283..deca88db2a7c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -715,6 +715,56 @@ setup for source maps in Sentry and will not require you to match stack frame pa To see the new options, check out the docs at https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/, or look at the TypeScript type definitions of `withSentryConfig`. +#### Updated the recommended way of calling `Sentry.init()` + +With version 8 of the SDK we will no longer support the use of `sentry.server.config.ts` and `sentry.edge.config.ts` +files. Instead, please initialize the Sentry Next.js SDK for the serverside in a +[Next.js instrumentation hook](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation). +**`sentry.client.config.ts|js` is still supported and encouraged for initializing the clientside SDK.** + +The following is an example of how to initialize the serverside SDK in a Next.js instrumentation hook: + +1. First, enable the Next.js instrumentation hook by setting the `experimental.instrumentationHook` to `true` in your + `next.config.js`. +2. Next, create a `instrumentation.ts|js` file in the root directory of your project (or in the `src` folder if you have + have one). +3. Now, export a `register` function from the `instrumentation.ts|js` file and call `Sentry.init()` inside of it: + + ```ts + import * as Sentry from '@sentry/nextjs'; + + export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + Sentry.init({ + dsn: 'YOUR_DSN', + // Your Node.js Sentry configuration... + }); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + dsn: 'YOUR_DSN', + // Your Edge Runtime Sentry configuration... + }); + } + } + ``` + + Note that you can initialize the SDK differently depending on which server runtime is being used. + +If you are using a +[Next.js custom server](https://nextjs.org/docs/pages/building-your-application/configuring/custom-server), the +`instrumentation.ts` hook is not called by Next.js so you need to manually call it yourself from within your server +code. It is recommended to do so as early as possible in your application lifecycle. + +**Why are we making this change?** The very simple reason is that Next.js requires us to set up OpenTelemetry +instrumentation inside the `register` function of the instrumentation hook. Looking a little bit further into the +future, we also would like the Sentry SDK to be compatible with [Turbopack](https://turbo.build/pack), which is gonna be +the bundler that Next.js will be using instead of Webpack. The SDK in its previous version depended heavily on Webpack +in order to inject the `sentry.(server|edge).config.ts` files into the server-side code. Because this will not be +possible in the future, we are doing ourselves a favor and doing things the way Next.js intends us to do them - +hopefully reducing bugs and jank. + ### Astro SDK #### Removal of `trackHeaders` option for Astro middleware diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/instrumentation.ts b/dev-packages/e2e-tests/test-applications/create-next-app/instrumentation.ts new file mode 100644 index 000000000000..5ddf6e7b823a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-next-app/instrumentation.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/nextjs'; + +declare global { + namespace globalThis { + var transactionIds: string[]; + } +} + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1.0, + integrations: [Sentry.localVariablesIntegration()], + }); + + Sentry.addEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/create-next-app/sentry.server.config.ts deleted file mode 100644 index 3750d0f5c5fd..000000000000 --- a/dev-packages/e2e-tests/test-applications/create-next-app/sentry.server.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from '@sentry/nextjs'; - -declare global { - namespace globalThis { - var transactionIds: string[]; - } -} - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - integrations: [Sentry.localVariablesIntegration()], -}); - -Sentry.addEventProcessor(event => { - global.transactionIds = global.transactionIds || []; - - if (event.type === 'transaction') { - const eventId = event.event_id; - - if (eventId) { - global.transactionIds.push(eventId); - } - } - - return event; -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts new file mode 100644 index 000000000000..6ede827b556a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts deleted file mode 100644 index 85bd765c9c44..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts deleted file mode 100644 index 85bd765c9c44..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation.ts new file mode 100644 index 000000000000..6ede827b556a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts deleted file mode 100644 index 85bd765c9c44..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts deleted file mode 100644 index 85bd765c9c44..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, - sendDefaultPii: true, -}); diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 119a591621df..c7afd15d46c5 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -17,35 +17,64 @@ ## Compatibility -Currently, the minimum Next.js supported version is `10.0.8`. +Currently, the minimum Next.js supported version is `11.2.0`. ## General This package is a wrapper around `@sentry/node` for the server and `@sentry/react` for the client, with added functionality related to Next.js. -To use this SDK, init it in the Sentry config files. +To use this SDK, initialize it in the Next.js configuration, in the `sentry.client.config.ts|js` file, and in the +[Next.js Instrumentation Hook](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation) +(`instrumentation.ts|js`). ```javascript -// sentry.client.config.js +// next.config.js + +const { withSentryConfig } = require('@sentry/nextjs'); + +const nextConfig = { + experimental: { + // The instrumentation hook is required for Sentry to work on the serverside + instrumentationHook: true, + }, +}; + +// Wrap the Next.js configuration with Sentry +module.exports = withSentryConfig(nextConfig); +``` + +```javascript +// sentry.client.config.js or .ts import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: '__DSN__', - // ... + // Your Sentry configuration for the Browser... }); ``` ```javascript -// sentry.server.config.js +// instrumentation.ts import * as Sentry from '@sentry/nextjs'; -Sentry.init({ - dsn: '__DSN__', - // ... -}); +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + Sentry.init({ + dsn: '__DSN__', + // Your Node.js Sentry configuration... + }); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + dsn: '__DSN__', + // Your Edge Runtime Sentry configuration... + }); + } +} ``` To set context information or send manual events, use the exported functions of `@sentry/nextjs`. diff --git a/packages/nextjs/playwright.config.ts b/packages/nextjs/playwright.config.ts index 917c05cb9ed3..97dfdd760da1 100644 --- a/packages/nextjs/playwright.config.ts +++ b/packages/nextjs/playwright.config.ts @@ -15,6 +15,8 @@ const config: PlaywrightTestConfig = { cwd: path.join(__dirname, 'test', 'integration'), command: 'yarn start', port: 3000, + stdout: 'pipe', + stderr: 'pipe', }, }; diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index dab8c767c54f..8689082de95b 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -26,9 +26,6 @@ const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplateP let showedMissingAsyncStorageModuleWarning = false; -const sentryInitWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'sentryInitWrapperTemplate.js'); -const sentryInitWrapperTemplateCode = fs.readFileSync(sentryInitWrapperTemplatePath, { encoding: 'utf8' }); - const serverComponentWrapperTemplatePath = path.resolve( __dirname, '..', @@ -45,8 +42,7 @@ export type WrappingLoaderOptions = { appDir: string | undefined; pageExtensionRegex: string; excludeServerRoutes: Array; - wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init' | 'route-handler'; - sentryConfigFilePath?: string; + wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler'; vercelCronsConfig?: VercelCronsConfig; nextjsRequestAsyncStorageModulePath?: string; }; @@ -70,7 +66,6 @@ export default function wrappingLoader( pageExtensionRegex, excludeServerRoutes = [], wrappingTargetKind, - sentryConfigFilePath, vercelCronsConfig, nextjsRequestAsyncStorageModulePath, } = 'getOptions' in this ? this.getOptions() : this.query; @@ -79,28 +74,7 @@ export default function wrappingLoader( let templateCode: string; - if (wrappingTargetKind === 'sentry-init') { - templateCode = sentryInitWrapperTemplateCode; - - // Absolute paths to the sentry config do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133 - // Se we need check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute. - // Examples where `this.resourcePath` could possibly be non-absolute are virtual modules. - if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) { - const sentryConfigImportPath = path - .relative(path.dirname(this.resourcePath), sentryConfigFilePath) - .replace(/\\/g, '/'); - - // path.relative() may return something like `sentry.server.config.js` which is not allowed. Imports from the - // current directory need to start with './'.This is why we prepend the path with './', which should always again - // be a valid relative path. - // https://github.com/getsentry/sentry-javascript/issues/8798 - templateCode = templateCode.replace(/__SENTRY_CONFIG_IMPORT_PATH__/g, `./${sentryConfigImportPath}`); - } else { - // Bail without doing any wrapping - this.callback(null, userCode, userModuleSourceMap); - return; - } - } else if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') { + if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') { if (pagesDir === undefined) { this.callback(null, userCode, userModuleSourceMap); return; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 75d4c87ac963..041d815d09d9 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -43,6 +43,10 @@ export type NextConfigObject = { fallback?: NextRewrite[]; } >; + // Next.js experimental options + experimental?: { + instrumentationHook?: boolean; + }; }; export type SentryBuildOptions = { diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0a8484c9afc8..2e7c8bb57a80 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -8,7 +8,6 @@ import { arrayify, escapeStringForRegex, loadModule, logger } from '@sentry/util import * as chalk from 'chalk'; import { sync as resolveSync } from 'resolve'; -import { DEBUG_BUILD } from '../common/debug-build'; import type { VercelCronsConfig } from '../common/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our // circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. @@ -124,7 +123,6 @@ export function constructWebpackConfigFunction( pagesDir: pagesDirPath, pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, - sentryConfigFilePath: getUserConfigFilePath(projectDir, runtime), nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation( projectDir, rawNewConfig.resolve?.modules, @@ -227,7 +225,12 @@ export function constructWebpackConfigFunction( // noop if file does not exist } else { // log but noop - logger.error(`${chalk.red('error')} - Sentry failed to read vercel.json`, e); + logger.error( + `${chalk.red( + 'error', + )} - Sentry failed to read vercel.json for automatic cron job monitoring instrumentation`, + e, + ); } } @@ -293,32 +296,9 @@ export function constructWebpackConfigFunction( }); } - if (isServer) { - // Import the Sentry config in every user file - newConfig.module.rules.unshift({ - test: resourcePath => { - return ( - isPageResource(resourcePath) || - isApiRouteResource(resourcePath) || - isMiddlewareResource(resourcePath) || - isServerComponentResource(resourcePath) || - isRouteHandlerResource(resourcePath) - ); - }, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - wrappingTargetKind: 'sentry-init', - }, - }, - ], - }); - } - if (appDirPath) { const hasGlobalErrorFile = ['global-error.js', 'global-error.jsx', 'global-error.ts', 'global-error.tsx'].some( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), ); @@ -364,19 +344,21 @@ export function constructWebpackConfigFunction( }); } - // Tell webpack to inject user config files (containing the two `Sentry.init()` calls) into the appropriate output - // bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do - // this, we'll have a statement of the form `x.y = () => f(x.y)`, where one of the things `f` does is call `x.y`. - // Since we're setting `x.y` to be a callback (which, by definition, won't run until some time later), by the time - // the function runs (causing `f` to run, causing `x.y` to run), `x.y` will point to the callback itself, rather - // than its original value. So calling it will call the callback which will call `f` which will call `x.y` which - // will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also - // be fixed by using `bind`, but this is way simpler.) - const origEntryProperty = newConfig.entry; - newConfig.entry = async () => addSentryToEntryProperty(origEntryProperty, buildContext, userSentryOptions); - - // Next doesn't let you change `devtool` in dev even if you want to, so don't bother trying - see - // https://github.com/vercel/next.js/blob/master/errors/improper-devtool.md + if (!isServer) { + // Tell webpack to inject the client config files (containing the client-side `Sentry.init()` call) into the appropriate output + // bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do + // this, we'll have a statement of the form `x.y = () => f(x.y)`, where one of the things `f` does is call `x.y`. + // Since we're setting `x.y` to be a callback (which, by definition, won't run until some time later), by the time + // the function runs (causing `f` to run, causing `x.y` to run), `x.y` will point to the callback itself, rather + // than its original value. So calling it will call the callback which will call `f` which will call `x.y` which + // will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also + // be fixed by using `bind`, but this is way simpler.) + const origEntryProperty = newConfig.entry; + newConfig.entry = async () => addSentryToClientEntryProperty(origEntryProperty, buildContext); + } + + // We don't want to do any webpack plugin stuff OR any source maps stuff in dev mode. + // Symbolication for dev-mode errors is done elsewhere. if (!isDev) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { sentryWebpackPlugin } = loadModule('@sentry/webpack-plugin') as any; @@ -480,7 +462,7 @@ function findTranspilationRules(rules: WebpackModuleRule[] | undefined, projectD } /** - * Modify the webpack `entry` property so that the code in `sentry.server.config.js` and `sentry.client.config.js` is + * Modify the webpack `entry` property so that the code in `sentry.client.config.js` is * included in the the necessary bundles. * * @param currentEntryProperty The value of the property before Sentry code has been injected @@ -488,10 +470,9 @@ function findTranspilationRules(rules: WebpackModuleRule[] | undefined, projectD * @returns The value which the new `entry` property (which will be a function) will return (TODO: this should return * the function, rather than the function's return value) */ -async function addSentryToEntryProperty( +async function addSentryToClientEntryProperty( currentEntryProperty: WebpackEntryProperty, buildContext: BuildContext, - userSentryOptions: SentryBuildOptions, ): Promise { // 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 @@ -499,37 +480,24 @@ async function addSentryToEntryProperty( // 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. - const { isServer, dir: projectDir, nextRuntime, dev: isDevMode } = buildContext; - const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'node') : 'browser'; + const { dir: projectDir, dev: isDevMode } = buildContext; const newEntryProperty = typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty }; - // `sentry.server.config.js` or `sentry.client.config.js` (or their TS equivalents) - const userConfigFile = - nextRuntime === 'edge' - ? getUserConfigFile(projectDir, 'edge') - : isServer - ? getUserConfigFile(projectDir, 'server') - : getUserConfigFile(projectDir, 'client'); + const clientSentryConfigFileName = getClientSentryConfigFile(projectDir); // we need to turn the filename into a path so webpack can find it - const filesToInject = userConfigFile ? [`./${userConfigFile}`] : []; + const filesToInject = clientSentryConfigFileName ? [`./${clientSentryConfigFileName}`] : []; // inject into all entry points which might contain user's code for (const entryPointName in newEntryProperty) { - if (shouldAddSentryToEntryPoint(entryPointName, runtime)) { - addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject, isDevMode); - } else { - if ( - isServer && - // If the user has asked to exclude pages, confirm for them that it's worked - userSentryOptions.excludeServerRoutes && - // We always skip these, so it's not worth telling the user that we've done so - !['pages/_app', 'pages/_document'].includes(entryPointName) - ) { - DEBUG_BUILD && logger.log(`Skipping Sentry injection for ${entryPointName.replace(/^pages/, '')}`); - } + if ( + entryPointName === 'pages/_app' || + // entrypoint for `/app` pages + entryPointName === 'main-app' + ) { + addFilesToWebpackEntryPoint(newEntryProperty, entryPointName, filesToInject, isDevMode); } } @@ -537,49 +505,39 @@ async function addSentryToEntryProperty( } /** - * Search the project directory for a valid user config file for the given platform, allowing for it to be either a - * TypeScript or JavaScript file. + * Searches for old `sentry.(server|edge).config.ts` files and warns if it finds any. * - * @param projectDir The root directory of the project, where the file should be located + * @param projectDir The root directory of the project, where config files would be located * @param platform Either "server", "client" or "edge", so that we know which file to look for - * @returns The name of the relevant file. If the server or client file is not found, this method throws an error. The - * edge file is optional, if it is not found this function will return `undefined`. */ -export function getUserConfigFile(projectDir: string, platform: 'server' | 'client' | 'edge'): string | undefined { +export function warnAboutDeprecatedConfigFiles(projectDir: string, platform: 'server' | 'client' | 'edge'): void { const possibilities = [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`]; for (const filename of possibilities) { if (fs.existsSync(path.resolve(projectDir, filename))) { - return filename; + if (platform === 'server' || platform === 'edge') { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] It seems you have configured a \`${filename}\` file. You need to put this file's content into a Next.js instrumentation hook instead! Read more: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation`, + ); + } } } - - // Edge config file is optional - if (platform === 'edge') { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You are using Next.js features that run on the Edge Runtime. Please add a "sentry.edge.config.js" or a "sentry.edge.config.ts" file to your project root in which you initialize the Sentry SDK with "Sentry.init()".', - ); - return; - } else { - throw new Error(`Cannot find '${possibilities[0]}' or '${possibilities[1]}' in '${projectDir}'.`); - } } /** - * Gets the absolute path to a sentry config file for a particular platform. Returns `undefined` if it doesn't exist. + * Searches for a `sentry.client.config.ts|js` file and returns it's file name if it finds one. (ts being prioritized) + * + * @param projectDir The root directory of the project, where config files would be located */ -export function getUserConfigFilePath(projectDir: string, platform: 'server' | 'client' | 'edge'): string | undefined { - const possibilities = [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`]; +export function getClientSentryConfigFile(projectDir: string): string | void { + const possibilities = ['sentry.client.config.ts', 'sentry.client.config.js']; for (const filename of possibilities) { - const configPath = path.resolve(projectDir, filename); - if (fs.existsSync(configPath)) { - return configPath; + if (fs.existsSync(path.resolve(projectDir, filename))) { + return filename; } } - - return undefined; } /** @@ -589,7 +547,7 @@ export function getUserConfigFilePath(projectDir: string, platform: 'server' | ' * @param entryPointName The key where the file should be injected * @param filesToInsert An array of paths to the injected files */ -function addFilesToExistingEntryPoint( +function addFilesToWebpackEntryPoint( entryProperty: EntryPropertyObject, entryPointName: string, filesToInsert: string[], @@ -653,23 +611,6 @@ function addFilesToExistingEntryPoint( entryProperty[entryPointName] = newEntryPoint; } -/** - * Determine if this is an entry point into which both `Sentry.init()` code and the release value should be injected - * - * @param entryPointName The name of the entry point in question - * @param isServer Whether or not this function is being called in the context of a server build - * @param excludeServerRoutes A list of excluded serverside entrypoints provided by the user - * @returns `true` if sentry code should be injected, and `false` otherwise - */ -function shouldAddSentryToEntryPoint(entryPointName: string, runtime: 'node' | 'browser' | 'edge'): boolean { - return ( - runtime === 'browser' && - (entryPointName === 'pages/_app' || - // entrypoint for `/app` pages - entryPointName === 'main-app') - ); -} - /** * Ensure that `newConfig.module.rules` exists. Modifies the given config in place but also returns it in order to * change its type. @@ -723,15 +664,16 @@ function addValueInjectionLoader( const clientValues = { ...isomorphicValues, // Get the path part of `assetPrefix`, minus any trailing slash. (We use a placeholder for the origin if - // `assetPreix` doesn't include one. Since we only care about the path, it doesn't matter what it is.) + // `assetPrefix` doesn't include one. Since we only care about the path, it doesn't matter what it is.) __rewriteFramesAssetPrefixPath__: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', }; - newConfig.module.rules.push( - { - test: /sentry\.(server|edge)\.config\.(jsx?|tsx?)/, + if (buildContext.isServer) { + newConfig.module.rules.push({ + // TODO: Find a more bulletproof way of matching. For now this is fine and doesn't hurt anyone. It merely sets some globals. + test: /(src[\\/])?instrumentation.(js|ts)/, use: [ { loader: path.resolve(__dirname, 'loaders/valueInjectionLoader.js'), @@ -740,8 +682,9 @@ function addValueInjectionLoader( }, }, ], - }, - { + }); + } else { + newConfig.module.rules.push({ test: /sentry\.client\.config\.(jsx?|tsx?)/, use: [ { @@ -751,8 +694,8 @@ function addValueInjectionLoader( }, }, ], - }, - ); + }); + } } function resolveNextPackageDirFromDirectory(basedir: string): string | undefined { @@ -764,7 +707,7 @@ function resolveNextPackageDirFromDirectory(basedir: string): string | undefined } } -const POTENTIAL_REQUEST_ASNYC_STORAGE_LOCATIONS = [ +const POTENTIAL_REQUEST_ASYNC_STORAGE_LOCATIONS = [ // Original location of RequestAsyncStorage // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts 'next/dist/client/components/request-async-storage.js', @@ -788,7 +731,7 @@ function getRequestAsyncStorageModuleLocation( for (const webpackResolvableLocation of absoluteWebpackResolvableModuleLocations) { const nextPackageDir = resolveNextPackageDirFromDirectory(webpackResolvableLocation); if (nextPackageDir) { - const asyncLocalStorageLocation = POTENTIAL_REQUEST_ASNYC_STORAGE_LOCATIONS.find(loc => + const asyncLocalStorageLocation = POTENTIAL_REQUEST_ASYNC_STORAGE_LOCATIONS.find(loc => fs.existsSync(path.join(nextPackageDir, '..', loc)), ); if (asyncLocalStorageLocation) { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index f9f03b0af2fd..0d68448f81ba 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -18,13 +18,14 @@ let showedExportModeTunnelWarning = false; * @param sentryBuildOptions Additional options to configure instrumentation and * @returns The modified config to be exported */ -export function withSentryConfig( - nextConfig: NextConfig = {}, - sentryBuildOptions: SentryBuildOptions = {}, -): NextConfigFunction | NextConfigObject { - if (typeof nextConfig === 'function') { +export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { + const castNextConfig = (nextConfig as NextConfig) || {}; + if (typeof castNextConfig === 'function') { return function (this: unknown, ...webpackConfigFunctionArgs: unknown[]): ReturnType { - const maybePromiseNextConfig: ReturnType = nextConfig.apply(this, webpackConfigFunctionArgs); + const maybePromiseNextConfig: ReturnType = castNextConfig.apply( + this, + webpackConfigFunctionArgs, + ); if (isThenable(maybePromiseNextConfig)) { return maybePromiseNextConfig.then(promiseResultNextConfig => { @@ -33,9 +34,9 @@ export function withSentryConfig( } return getFinalConfigObject(maybePromiseNextConfig, sentryBuildOptions); - }; + } as C; } else { - return getFinalConfigObject(nextConfig, sentryBuildOptions); + return getFinalConfigObject(castNextConfig, sentryBuildOptions) as C; } } @@ -69,6 +70,18 @@ function getFinalConfigObject( } } + // We need to enable `instrumentation.ts` for users because we tell them to put their `Sentry.init()` calls inside of it. + if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You turned off the `instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.ts`.', + ); + } + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + return { ...incomingUserNextConfigObject, webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions), diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index ed3589363539..c2aaf0c9a707 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -77,7 +77,7 @@ describe('webpack loaders', () => { }); expect(finalWebpackConfig.module.rules).toContainEqual({ - test: /sentry\.(server|edge)\.config\.(jsx?|tsx?)/, + test: expect.any(RegExp), use: [ { loader: expect.stringEndingWith('valueInjectionLoader.js'), diff --git a/packages/nextjs/test/config/webpack/webpack.test.ts b/packages/nextjs/test/config/webpack/webpack.test.ts deleted file mode 100644 index d0f8606e3b4c..000000000000 --- a/packages/nextjs/test/config/webpack/webpack.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { getUserConfigFile } from '../../../src/config/webpack'; -import { exitsSync, mkdtempSyncSpy, mockExistsSync, realExistsSync } from '../mocks'; - -describe('getUserConfigFile', () => { - let tempDir: string; - - beforeAll(() => { - exitsSync.mockImplementation(realExistsSync); - }); - - beforeEach(() => { - // these will get cleaned up by the file's overall `afterAll` function, and the `mkdtempSync` mock above ensures - // that the location of the created folder is stored in `tempDir` - const tempDirPathPrefix = path.join(os.tmpdir(), 'sentry-nextjs-test-'); - fs.mkdtempSync(tempDirPathPrefix); - tempDir = mkdtempSyncSpy.mock.results[0].value; - }); - - afterAll(() => { - exitsSync.mockImplementation(mockExistsSync); - }); - - it('successfully finds js files', () => { - fs.writeFileSync(path.resolve(tempDir, 'sentry.server.config.js'), 'Dogs are great!'); - fs.writeFileSync(path.resolve(tempDir, 'sentry.client.config.js'), 'Squirrel!'); - - expect(getUserConfigFile(tempDir, 'server')).toEqual('sentry.server.config.js'); - expect(getUserConfigFile(tempDir, 'client')).toEqual('sentry.client.config.js'); - }); - - it('successfully finds ts files', () => { - fs.writeFileSync(path.resolve(tempDir, 'sentry.server.config.ts'), 'Sit. Stay. Lie Down.'); - fs.writeFileSync(path.resolve(tempDir, 'sentry.client.config.ts'), 'Good dog!'); - - expect(getUserConfigFile(tempDir, 'server')).toEqual('sentry.server.config.ts'); - expect(getUserConfigFile(tempDir, 'client')).toEqual('sentry.client.config.ts'); - }); - - it('errors when files are missing', () => { - expect(() => getUserConfigFile(tempDir, 'server')).toThrowError( - `Cannot find 'sentry.server.config.ts' or 'sentry.server.config.js' in '${tempDir}'`, - ); - expect(() => getUserConfigFile(tempDir, 'client')).toThrowError( - `Cannot find 'sentry.client.config.ts' or 'sentry.client.config.js' in '${tempDir}'`, - ); - }); -}); diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts index eec179725e74..4458a9ce16b6 100644 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ b/packages/nextjs/test/config/wrappingLoader.test.ts @@ -86,7 +86,6 @@ describe('wrappingLoader', () => { pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, excludeServerRoutes: [], wrappingTargetKind: 'api-route', - sentryConfigFilePath: '/my/sentry.server.config.ts', vercelCronsConfig: undefined, nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', }; diff --git a/packages/nextjs/test/integration/instrumentation.ts b/packages/nextjs/test/integration/instrumentation.ts new file mode 100644 index 000000000000..b2ea76760101 --- /dev/null +++ b/packages/nextjs/test/integration/instrumentation.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/nextjs'; + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + tracePropagationTargets: ['http://example.com'], + debug: !!process.env.SDK_DEBUG, + integrations: defaults => [ + ...defaults.filter( + integration => + // filter out `Console` since the tests are happening in the console and we don't need to record what's printed + // there, because we can see it (this makes debug logging much less noisy, since intercepted events which are + // printed to the console no longer create console breadcrumbs, which then get printed, creating even longer + // console breadcrumbs, which get printed, etc, etc) + integration.name !== 'Console', + ), + ], + }); + } +} diff --git a/packages/nextjs/test/integration/next-env.d.ts b/packages/nextjs/test/integration/next-env.d.ts index fd36f9494e2c..4f11a03dc6cc 100644 --- a/packages/nextjs/test/integration/next-env.d.ts +++ b/packages/nextjs/test/integration/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/nextjs/test/integration/next.config.js b/packages/nextjs/test/integration/next.config.js index 815eba98e889..e9e4e4e04b2e 100644 --- a/packages/nextjs/test/integration/next.config.js +++ b/packages/nextjs/test/integration/next.config.js @@ -4,6 +4,9 @@ const moduleExports = { eslint: { ignoreDuringBuilds: true, }, + experimental: { + appDir: Number(process.env.NODE_MAJOR) >= 16, // experimental.appDir requires Node v16.8.0 or later. + }, pageExtensions: ['jsx', 'js', 'tsx', 'ts', 'page.tsx'], }; diff --git a/packages/nextjs/test/integration/next13.appdir.config.template b/packages/nextjs/test/integration/next13.appdir.config.template index 815eba98e889..e9e4e4e04b2e 100644 --- a/packages/nextjs/test/integration/next13.appdir.config.template +++ b/packages/nextjs/test/integration/next13.appdir.config.template @@ -4,6 +4,9 @@ const moduleExports = { eslint: { ignoreDuringBuilds: true, }, + experimental: { + appDir: Number(process.env.NODE_MAJOR) >= 16, // experimental.appDir requires Node v16.8.0 or later. + }, pageExtensions: ['jsx', 'js', 'tsx', 'ts', 'page.tsx'], }; diff --git a/packages/nextjs/test/integration/sentry.edge.config.js b/packages/nextjs/test/integration/sentry.edge.config.js deleted file mode 100644 index 36600e702048..000000000000 --- a/packages/nextjs/test/integration/sentry.edge.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1.0, - tracePropagationTargets: ['http://example.com'], - debug: process.env.SDK_DEBUG, -}); diff --git a/packages/nextjs/test/integration/sentry.server.config.js b/packages/nextjs/test/integration/sentry.server.config.js deleted file mode 100644 index 54c5db73a1a2..000000000000 --- a/packages/nextjs/test/integration/sentry.server.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1.0, - tracePropagationTargets: ['http://example.com'], - debug: process.env.SDK_DEBUG, - - integrations: defaults => [ - ...defaults.filter( - integration => - // filter out `Console` since the tests are happening in the console and we don't need to record what's printed - // there, because we can see it (this makes debug logging much less noisy, since intercepted events which are - // printed to the console no longer create console breadcrumbs, which then get printed, creating even longer - // console breadcrumbs, which get printed, etc, etc) - - // filter out `Http` so its options can be changed below (otherwise, default one wins because it's initialized first) - integration.name !== 'Console' && integration.name !== 'Http', - ), - - // Used for testing http tracing - new Sentry.Integrations.Http({ tracing: true }), - ], -}); diff --git a/packages/nextjs/test/integration/test/server/utils/helpers.ts b/packages/nextjs/test/integration/test/server/utils/helpers.ts index 590590c8710d..badc22c18424 100644 --- a/packages/nextjs/test/integration/test/server/utils/helpers.ts +++ b/packages/nextjs/test/integration/test/server/utils/helpers.ts @@ -5,6 +5,9 @@ import * as path from 'path'; import { parse } from 'url'; import next from 'next'; import { TestEnv } from '../../../../../../../dev-packages/node-integration-tests/utils'; +import { register } from '../../../instrumentation'; + +let initializedSdk = false; // Type not exported from NextJS // @ts-expect-error @@ -40,6 +43,13 @@ export class NextTestEnv extends TestEnv { } public static async init(): Promise { + if (!initializedSdk) { + // Normally, Next.js calls the `register` hook by itself, but since we are using a custom server for the tests we need to do it manually. + process.env.NEXT_RUNTIME = 'nodejs'; + await register(); + initializedSdk = true; + } + const server = await createNextServer({ dev: false, dir: path.resolve(__dirname, '../../..'), diff --git a/packages/nextjs/test/run-integration-tests.sh b/packages/nextjs/test/run-integration-tests.sh index 834bd34d659b..17e8ace8f446 100755 --- a/packages/nextjs/test/run-integration-tests.sh +++ b/packages/nextjs/test/run-integration-tests.sh @@ -45,23 +45,25 @@ for NEXTJS_VERSION in 13; do export NODE_MAJOR=$NODE_MAJOR export USE_APPDIR=$USE_APPDIR - # Next.js v13 requires at least Node v16 - if [ "$NODE_MAJOR" -lt "16" ] && [ "$NEXTJS_VERSION" -ge "13" ]; then - echo "[nextjs@$NEXTJS_VERSION] Not compatible with Node $NODE_MAJOR" - exit 0 - fi - echo "[nextjs@$NEXTJS_VERSION] Preparing environment..." rm -rf node_modules .next .env.local 2>/dev/null || true echo "[nextjs@$NEXTJS_VERSION] Installing dependencies..." + + # Pin to a specific version + if [ "$NEXTJS_VERSION" -eq "13" ]; then + NEXTJS_PACKAGE_JSON_VERSION="13.2.0" + else + NEXTJS_PACKAGE_JSON_VERSION="$NEXTJS_VERSION.x" + fi + # set the desired version of next long enough to run yarn, and then restore the old version (doing the restoration now # rather than during overall cleanup lets us look for "latest" in every loop) cp package.json package.json.bak if [[ $(uname) == "Darwin" ]]; then - sed -i "" /"next.*latest"/s/latest/"${NEXTJS_VERSION}.x"/ package.json + sed -i "" /"next.*latest"/s/latest/"${NEXTJS_PACKAGE_JSON_VERSION}"/ package.json else - sed -i /"next.*latest"/s/latest/"${NEXTJS_VERSION}.x"/ package.json + sed -i /"next.*latest"/s/latest/"${NEXTJS_PACKAGE_JSON_VERSION}"/ package.json fi # Yarn install randomly started failing because it couldn't find some cache so for now we need to run these two commands which seem to fix it.