Skip to content

[WIP]feat(nextjs): Use compiler hook for uploading sourcemaps #17352

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

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,15 @@
"@opentelemetry/semantic-conventions": "^1.34.0",
"@rollup/plugin-commonjs": "28.0.1",
"@sentry-internal/browser-utils": "10.1.0",
"@sentry/bundler-plugin-core": "^4.0.2",
"@sentry/core": "10.1.0",
"@sentry/node": "10.1.0",
"@sentry/opentelemetry": "10.1.0",
"@sentry/react": "10.1.0",
"@sentry/vercel-edge": "10.1.0",
"@sentry/webpack-plugin": "^4.0.2",
"chalk": "3.0.0",
"glob": "^11.0.3",
"resolve": "1.22.8",
"rollup": "^4.35.0",
"stacktrace-parser": "^0.1.10"
Expand Down
97 changes: 97 additions & 0 deletions packages/nextjs/src/config/getBuildPluginOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin-core';
import * as path from 'path';
import type { SentryBuildOptions } from './types';

/**
* Get Sentry Build Plugin options for the runAfterProductionCompile hook.
*/
export function getBuildPluginOptions({
sentryBuildOptions,
releaseName,
distDirAbsPath,
}: {
sentryBuildOptions: SentryBuildOptions;
releaseName: string | undefined;
distDirAbsPath: string;
}): SentryBuildPluginOptions {
const sourcemapUploadAssets: string[] = [];
const sourcemapUploadIgnore: string[] = [];

const filesToDeleteAfterUpload: string[] = [];

// We need to convert paths to posix because Glob patterns use `\` to escape
// glob characters. This clashes with Windows path separators.
// See: https://www.npmjs.com/package/glob
const normalizedDistDirAbsPath = distDirAbsPath.replace(/\\/g, '/');

sourcemapUploadAssets.push(
path.posix.join(normalizedDistDirAbsPath, '**'), // Next.js build output
);
if (sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload) {
filesToDeleteAfterUpload.push(
path.posix.join(normalizedDistDirAbsPath, '**', '*.js.map'),
path.posix.join(normalizedDistDirAbsPath, '**', '*.mjs.map'),
path.posix.join(normalizedDistDirAbsPath, '**', '*.cjs.map'),
);
}

return {
authToken: sentryBuildOptions.authToken,
headers: sentryBuildOptions.headers,
org: sentryBuildOptions.org,
project: sentryBuildOptions.project,
telemetry: sentryBuildOptions.telemetry,
debug: sentryBuildOptions.debug,
errorHandler: sentryBuildOptions.errorHandler,
reactComponentAnnotation: {
...sentryBuildOptions.reactComponentAnnotation,
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation,
},
silent: sentryBuildOptions.silent,
url: sentryBuildOptions.sentryUrl,
sourcemaps: {
disable: sentryBuildOptions.sourcemaps?.disable,
rewriteSources(source) {
if (source.startsWith('webpack://_N_E/')) {
return source.replace('webpack://_N_E/', '');
} else if (source.startsWith('webpack://')) {
return source.replace('webpack://', '');
} else {
return source;
}
},
assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets,
ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore,
filesToDeleteAfterUpload,
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps,
},
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,
},
_metaOptions: {
loggerPrefixOverride: '[@sentry/nextjs]',
telemetry: {
metaFramework: 'nextjs',
},
},
...sentryBuildOptions.unstable_sentryWebpackPluginOptions,
};
}
62 changes: 62 additions & 0 deletions packages/nextjs/src/config/handleRunAfterProductionCompile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core';
import { loadModule } from '@sentry/core';
import { glob } from 'glob';
import { getBuildPluginOptions } from './getBuildPluginOptions';
import type { SentryBuildOptions } from './types';

/**
* This function is called by Next.js after the production build is complete.
* It is used to upload sourcemaps to Sentry.
*/
export async function handleRunAfterProductionCompile(
{ releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' },
sentryBuildOptions: SentryBuildOptions,
): Promise<void> {
if (sentryBuildOptions.debug) {
// eslint-disable-next-line no-console
console.debug('[@sentry/nextjs] Running runAfterProductionCompile logic.');
}

const { createSentryBuildPluginManager } =
loadModule<{ createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType }>(
'@sentry/bundler-plugin-core',
module,
) ?? {};

if (!createSentryBuildPluginManager) {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/nextjs] Could not load build manager package. Will not run runAfterProductionCompile logic.',
);
return;
}

const sentryBuildPluginManager = createSentryBuildPluginManager(
getBuildPluginOptions({
sentryBuildOptions,
releaseName,
distDirAbsPath: distDir,
}),
{
buildTool,
loggerPrefix: '[@sentry/nextjs]',
},
);

const buildArtifactsPromise = glob(
['/**/*.js', '/**/*.mjs', '/**/*.cjs', '/**/*.js.map', '/**/*.mjs.map', '/**/*.cjs.map'].map(
q => `${q}?(\\?*)?(#*)`, // We want to allow query and hashes strings at the end of files
),
{
root: distDir,
absolute: true,
nodir: true,
},
);

await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal();
await sentryBuildPluginManager.createRelease();
// 🔜 await sentryBuildPluginManager.injectDebugIds();
await sentryBuildPluginManager.uploadSourcemaps(await buildArtifactsPromise);
await sentryBuildPluginManager.deleteArtifacts();
}
13 changes: 13 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export type NextConfigObject = {
env?: Record<string, string>;
serverExternalPackages?: string[]; // next >= v15.0.0
turbopack?: TurbopackOptions;
compiler?: {
runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise<void> | void;
};
};

export type SentryBuildOptions = {
Expand Down Expand Up @@ -498,6 +501,16 @@ export type SentryBuildOptions = {
*/
disableSentryWebpackConfig?: boolean;

/**
* When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads
* into a single operation after all webpack/turbopack builds complete, reducing build time.
*
* When false, use the traditional approach of uploading sourcemaps during each webpack build.
*
* @default false
*/
useRunAfterProductionCompileHook?: boolean;

/**
* Contains a set of experimental flags that might change in future releases. These flags enable
* features that are still in development and may be modified, renamed, or removed without notice.
Expand Down
37 changes: 37 additions & 0 deletions packages/nextjs/src/config/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseSemver } from '@sentry/core';
import * as fs from 'fs';
import { sync as resolveSync } from 'resolve';

Expand Down Expand Up @@ -27,3 +28,39 @@ function resolveNextjsPackageJson(): string | undefined {
return undefined;
}
}

/**
* Checks if the current Next.js version supports the runAfterProductionCompile hook.
* This hook was introduced in Next.js 15.4.1. (https://github.com/vercel/next.js/pull/77345)
*
* @returns true if Next.js version is 15.4.1 or higher
*/
export function supportsProductionCompileHook(): boolean {
const version = getNextjsVersion();
if (!version) {
return false;
}

const { major, minor, patch } = parseSemver(version);

if (major === undefined || minor === undefined || patch === undefined) {
return false;
}

if (major > 15) {
return true;
}

// For major version 15, check if it's 15.4.1 or higher
if (major === 15) {
if (minor > 4) {
return true;
}
if (minor === 4 && patch >= 1) {
return true;
}
return false;
}

return false;
}
7 changes: 6 additions & 1 deletion packages/nextjs/src/config/webpackPluginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ export function getWebpackPluginOptions(
silent: sentryBuildOptions.silent,
url: sentryBuildOptions.sentryUrl,
sourcemaps: {
// if the user has enabled the runAfterProductionCompileHook, we handle sourcemap uploads a later step
disable: sentryBuildOptions.sourcemaps?.disable,
// TODO: disable: sentryBuildOptions.useRunAfterProductionCompileHook
// ? 'disable-upload'
// : sentryBuildOptions.sourcemaps?.disable,
rewriteSources(source) {
if (source.startsWith('webpack://_N_E/')) {
return source.replace('webpack://_N_E/', '');
Expand All @@ -95,7 +99,8 @@ export function getWebpackPluginOptions(
...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps,
},
release:
releaseName !== undefined
// if the user has enabled the runAfterProductionCompileHook, we handle release creation a later step
releaseName !== undefined && !sentryBuildOptions.useRunAfterProductionCompileHook
? {
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,
Expand Down
34 changes: 33 additions & 1 deletion packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getSentryRelease } from '@sentry/node';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { handleRunAfterProductionCompile } from './handleRunAfterProductionCompile';
import { createRouteManifest } from './manifest/createRouteManifest';
import type { RouteManifest } from './manifest/types';
import { constructTurbopackConfig } from './turbopack';
Expand All @@ -14,7 +15,7 @@ import type {
NextConfigObject,
SentryBuildOptions,
} from './types';
import { getNextjsVersion } from './util';
import { getNextjsVersion, supportsProductionCompileHook } from './util';
import { constructWebpackConfigFunction } from './webpack';

let showedExportModeTunnelWarning = false;
Expand Down Expand Up @@ -293,6 +294,37 @@ function getFinalConfigObject(
}
}

if (userSentryOptions.useRunAfterProductionCompileHook === true && supportsProductionCompileHook()) {
if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) {
incomingUserNextConfigObject.compiler ??= {};
incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => {
await handleRunAfterProductionCompile(
{ releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' },
userSentryOptions,
);
};
} else if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') {
incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy(
incomingUserNextConfigObject.compiler.runAfterProductionCompile,
{
async apply(target, thisArg, argArray) {
const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' };
await target.apply(thisArg, argArray);
await handleRunAfterProductionCompile(
{ releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' },
userSentryOptions,
);
},
},
);
} else {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.',
);
}
}

return {
...incomingUserNextConfigObject,
...(nextMajor && nextMajor >= 15
Expand Down
Loading