Skip to content

feat(nextjs)!: Don't rely on Next.js Build ID for release names #14939

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
Jan 9, 2025
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
6 changes: 6 additions & 0 deletions docs/migration/v8-to-v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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
Expand Down
11 changes: 5 additions & 6 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -580,6 +580,7 @@ function addValueInjectionLoader(
userNextConfig: NextConfigObject,
userSentryOptions: SentryBuildOptions,
buildContext: BuildContext,
releaseName: string | undefined,
): void {
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';

Expand All @@ -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,
};

Expand Down
33 changes: 20 additions & 13 deletions packages/nextjs/src/config/webpackPluginOptions.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';

Expand Down Expand Up @@ -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,
},
Expand Down
20 changes: 18 additions & 2 deletions packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<C>(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C {
const castNextConfig = (nextConfig as NextConfig) || {};
if (typeof castNextConfig === 'function') {
Expand Down Expand Up @@ -174,9 +175,11 @@ function getFinalConfigObject(
);
}

const releaseName = userSentryOptions.release?.name ?? getSentryRelease() ?? getGitRevision();

return {
...incomingUserNextConfigObject,
webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions),
webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName),
};
}

Expand Down Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions packages/nextjs/test/config/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export async function materializeFinalWebpackConfig(options: {
const webpackConfigFunction = constructWebpackConfigFunction(
materializedUserNextConfig,
options.sentryBuildTimeOptions,
undefined,
);

// call it to get concrete values for comparison
Expand Down
95 changes: 58 additions & 37 deletions packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand All @@ -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: [],
Expand All @@ -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: [
Expand All @@ -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: [
Expand All @@ -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: [],
});
Expand All @@ -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: [
Expand All @@ -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,
},
});
});
});
Loading