diff --git a/docs/migration/v8-to-v9.md b/docs/migration/v8-to-v9.md index 3845c968772d..84e0526d102d 100644 --- a/docs/migration/v8-to-v9.md +++ b/docs/migration/v8-to-v9.md @@ -92,6 +92,12 @@ In v9, an `undefined` value will be treated the same as if the value is not defi - The `captureUserFeedback` method has been removed. Use `captureFeedback` instead and update the `comments` field to `message`. +### `@sentry/nextjs` + +- The Sentry Next.js SDK will no longer use the Next.js Build ID as fallback identifier for releases. The SDK will continue to attempt to read CI-provider-specific environment variables and the current git SHA to automatically determine a release name. If you examine that you no longer see releases created in Sentry, it is recommended to manually provide a release name to `withSentryConfig` via the `release.name` option. + + This behavior was changed because the Next.js Build ID is non-deterministic and the release name is injected into client bundles, causing build artifacts to be non-deterministic. This caused issues for some users. Additionally, because it is uncertain whether it will be possible to rely on a Build ID when Turbopack becomes stable, we decided to pull the plug now instead of introducing confusing behavior in the future. + ### Uncategorized (TODO) TODO diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index d8a29d7d025c..9a218bda6435 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -4,7 +4,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { escapeStringForRegex, loadModule, logger } from '@sentry/core'; -import { getSentryRelease } from '@sentry/node'; import * as chalk from 'chalk'; import { sync as resolveSync } from 'resolve'; @@ -43,6 +42,7 @@ let showedMissingGlobalErrorWarningMsg = false; export function constructWebpackConfigFunction( userNextConfig: NextConfigObject = {}, userSentryOptions: SentryBuildOptions = {}, + releaseName: string | undefined, ): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that @@ -71,7 +71,7 @@ export function constructWebpackConfigFunction( const newConfig = setUpModuleRules(rawNewConfig); // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext); + addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName); addOtelWarningIgnoreRule(newConfig); @@ -358,7 +358,7 @@ export function constructWebpackConfigFunction( newConfig.plugins = newConfig.plugins || []; const sentryWebpackPluginInstance = sentryWebpackPlugin( - getWebpackPluginOptions(buildContext, userSentryOptions), + getWebpackPluginOptions(buildContext, userSentryOptions, releaseName), ); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access sentryWebpackPluginInstance._name = 'sentry-webpack-plugin'; // For tests and debugging. Serves no other purpose. @@ -580,6 +580,7 @@ function addValueInjectionLoader( userNextConfig: NextConfigObject, userSentryOptions: SentryBuildOptions, buildContext: BuildContext, + releaseName: string | undefined, ): void { const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; @@ -592,9 +593,7 @@ function addValueInjectionLoader( // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode - SENTRY_RELEASE: buildContext.dev - ? undefined - : { id: userSentryOptions.release?.name ?? getSentryRelease(buildContext.buildId) }, + SENTRY_RELEASE: releaseName && !buildContext.dev ? { id: releaseName } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, }; diff --git a/packages/nextjs/src/config/webpackPluginOptions.ts b/packages/nextjs/src/config/webpackPluginOptions.ts index 7b183047896a..43aea096bdaa 100644 --- a/packages/nextjs/src/config/webpackPluginOptions.ts +++ b/packages/nextjs/src/config/webpackPluginOptions.ts @@ -1,5 +1,4 @@ import * as path from 'path'; -import { getSentryRelease } from '@sentry/node'; import type { SentryWebpackPluginOptions } from '@sentry/webpack-plugin'; import type { BuildContext, NextConfigObject, SentryBuildOptions } from './types'; @@ -10,8 +9,9 @@ import type { BuildContext, NextConfigObject, SentryBuildOptions } from './types export function getWebpackPluginOptions( buildContext: BuildContext, sentryBuildOptions: SentryBuildOptions, + releaseName: string | undefined, ): SentryWebpackPluginOptions { - const { buildId, isServer, config: userNextConfig, dir, nextRuntime } = buildContext; + const { isServer, config: userNextConfig, dir, nextRuntime } = buildContext; const prefixInsert = !isServer ? 'Client' : nextRuntime === 'edge' ? 'Edge' : 'Node.js'; @@ -92,17 +92,24 @@ export function getWebpackPluginOptions( : undefined, ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, }, - release: { - inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. - name: sentryBuildOptions.release?.name ?? getSentryRelease(buildId), - create: sentryBuildOptions.release?.create, - finalize: sentryBuildOptions.release?.finalize, - dist: sentryBuildOptions.release?.dist, - vcsRemote: sentryBuildOptions.release?.vcsRemote, - setCommits: sentryBuildOptions.release?.setCommits, - deploy: sentryBuildOptions.release?.deploy, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, - }, + release: + releaseName !== undefined + ? { + inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. + name: releaseName, + create: sentryBuildOptions.release?.create, + finalize: sentryBuildOptions.release?.finalize, + dist: sentryBuildOptions.release?.dist, + vcsRemote: sentryBuildOptions.release?.vcsRemote, + setCommits: sentryBuildOptions.release?.setCommits, + deploy: sentryBuildOptions.release?.deploy, + ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, + } + : { + inject: false, + create: false, + finalize: false, + }, bundleSizeOptimizations: { ...sentryBuildOptions.bundleSizeOptimizations, }, diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 4c815498b1db..99140ab46fc9 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -1,7 +1,9 @@ /* eslint-disable complexity */ import { isThenable, parseSemver } from '@sentry/core'; +import * as childProcess from 'child_process'; import * as fs from 'fs'; +import { getSentryRelease } from '@sentry/node'; import { sync as resolveSync } from 'resolve'; import type { ExportedNextConfig as NextConfig, @@ -20,7 +22,6 @@ let showedExportModeTunnelWarning = false; * @param sentryBuildOptions Additional options to configure instrumentation and * @returns The modified config to be exported */ -// TODO(v9): Always return an async function here to allow us to do async things like grabbing a deterministic build ID. export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { const castNextConfig = (nextConfig as NextConfig) || {}; if (typeof castNextConfig === 'function') { @@ -174,9 +175,11 @@ function getFinalConfigObject( ); } + const releaseName = userSentryOptions.release?.name ?? getSentryRelease() ?? getGitRevision(); + return { ...incomingUserNextConfigObject, - webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions), + webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName), }; } @@ -316,3 +319,16 @@ function resolveNextjsPackageJson(): string | undefined { return undefined; } } + +function getGitRevision(): string | undefined { + let gitRevision: string | undefined; + try { + gitRevision = childProcess + .execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch (e) { + // noop + } + return gitRevision; +} diff --git a/packages/nextjs/test/config/testUtils.ts b/packages/nextjs/test/config/testUtils.ts index 1e93e3740152..19e2a8f1c326 100644 --- a/packages/nextjs/test/config/testUtils.ts +++ b/packages/nextjs/test/config/testUtils.ts @@ -69,6 +69,7 @@ export async function materializeFinalWebpackConfig(options: { const webpackConfigFunction = constructWebpackConfigFunction( materializedUserNextConfig, options.sentryBuildTimeOptions, + undefined, ); // call it to get concrete values for comparison diff --git a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts b/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts index 177077d2b5c4..d6af815d13cb 100644 --- a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts +++ b/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts @@ -24,36 +24,40 @@ function generateBuildContext(overrides: { describe('getWebpackPluginOptions()', () => { it('forwards relevant options', () => { const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { - authToken: 'my-auth-token', - headers: { 'my-test-header': 'test' }, - org: 'my-org', - project: 'my-project', - telemetry: false, - reactComponentAnnotation: { - enabled: true, - }, - silent: false, - debug: true, - sentryUrl: 'my-url', - sourcemaps: { - assets: ['my-asset'], - ignore: ['my-ignore'], - }, - release: { - name: 'my-release', - create: false, - finalize: false, - dist: 'my-dist', - vcsRemote: 'my-origin', - setCommits: { - auto: true, + const generatedPluginOptions = getWebpackPluginOptions( + buildContext, + { + authToken: 'my-auth-token', + headers: { 'my-test-header': 'test' }, + org: 'my-org', + project: 'my-project', + telemetry: false, + reactComponentAnnotation: { + enabled: true, }, - deploy: { - env: 'my-env', + silent: false, + debug: true, + sentryUrl: 'my-url', + sourcemaps: { + assets: ['my-asset'], + ignore: ['my-ignore'], + }, + release: { + name: 'my-release', + create: false, + finalize: false, + dist: 'my-dist', + vcsRemote: 'my-origin', + setCommits: { + auto: true, + }, + deploy: { + env: 'my-env', + }, }, }, - }); + 'my-release', + ); expect(generatedPluginOptions.authToken).toBe('my-auth-token'); expect(generatedPluginOptions.debug).toBe(true); @@ -111,12 +115,16 @@ describe('getWebpackPluginOptions()', () => { it('forwards bundleSizeOptimization options', () => { const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { - bundleSizeOptimizations: { - excludeTracing: true, - excludeReplayShadowDom: false, + const generatedPluginOptions = getWebpackPluginOptions( + buildContext, + { + bundleSizeOptimizations: { + excludeTracing: true, + excludeReplayShadowDom: false, + }, }, - }); + undefined, + ); expect(generatedPluginOptions).toMatchObject({ bundleSizeOptimizations: { @@ -128,7 +136,7 @@ describe('getWebpackPluginOptions()', () => { it('returns the right `assets` and `ignore` values during the server build', () => { const buildContext = generateBuildContext({ isServer: true }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); expect(generatedPluginOptions.sourcemaps).toMatchObject({ assets: ['/my/project/dir/.next/server/**', '/my/project/dir/.next/serverless/**'], ignore: [], @@ -137,7 +145,7 @@ describe('getWebpackPluginOptions()', () => { it('returns the right `assets` and `ignore` values during the client build', () => { const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); expect(generatedPluginOptions.sourcemaps).toMatchObject({ assets: ['/my/project/dir/.next/static/chunks/pages/**', '/my/project/dir/.next/static/chunks/app/**'], ignore: [ @@ -152,7 +160,7 @@ describe('getWebpackPluginOptions()', () => { it('returns the right `assets` and `ignore` values during the client build with `widenClientFileUpload`', () => { const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }, undefined); expect(generatedPluginOptions.sourcemaps).toMatchObject({ assets: ['/my/project/dir/.next/static/chunks/**'], ignore: [ @@ -167,7 +175,7 @@ describe('getWebpackPluginOptions()', () => { it('sets `sourcemaps.assets` to an empty array when `sourcemaps.disable` is true', () => { const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { sourcemaps: { disable: true } }); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, { sourcemaps: { disable: true } }, undefined); expect(generatedPluginOptions.sourcemaps).toMatchObject({ assets: [], }); @@ -179,7 +187,7 @@ describe('getWebpackPluginOptions()', () => { nextjsConfig: { distDir: '.dist\\v1' }, isServer: false, }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }, undefined); expect(generatedPluginOptions.sourcemaps).toMatchObject({ assets: ['C:/my/windows/project/dir/.dist/v1/static/chunks/**'], ignore: [ @@ -191,4 +199,17 @@ describe('getWebpackPluginOptions()', () => { ], }); }); + + it('sets options to not create a release or do any release operations when releaseName is undefined', () => { + const buildContext = generateBuildContext({ isServer: false }); + const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}, undefined); + + expect(generatedPluginOptions).toMatchObject({ + release: { + inject: false, + create: false, + finalize: false, + }, + }); + }); });