diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index a529629e4413..d36e3a4ace38 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -554,7 +554,7 @@ export function createSentryBuildPluginManager( })), ); - await cliInstance.uploadSourcemaps(releaseName, uploadTargets); + warnAboutDirectoriesWithoutSourcemaps(logger, await cliInstance.uploadSourcemaps(releaseName, uploadTargets)); } if (options.release.setCommits !== false) { @@ -686,9 +686,9 @@ export function createSentryBuildPluginManager( return; } - await startSpan({ name: 'upload', scope: sentryScope }, async () => { + const emptyDirectories = await startSpan({ name: 'upload', scope: sentryScope }, async () => { const cliInstance = new SentryCliAdapter(options); - await cliInstance.uploadSourcemaps( + return cliInstance.uploadSourcemaps( options.release.name ?? 'undefined', pathsToUpload.map(directory => ({ directory, @@ -698,8 +698,11 @@ export function createSentryBuildPluginManager( ); }); - uploaded = true; - logger.info('Successfully uploaded source maps to Sentry'); + warnAboutDirectoriesWithoutSourcemaps(logger, emptyDirectories); + if (emptyDirectories.length < pathsToUpload.length) { + uploaded = true; + logger.info('Successfully uploaded source maps to Sentry'); + } } else { // Prepare artifacts in temp folder before uploading let globAssets: string | string[]; @@ -1029,3 +1032,13 @@ function canUploadSourceMaps(options: NormalizedOptions, logger: Logger, isDevMo return true; } } + +function warnAboutDirectoriesWithoutSourcemaps(logger: Logger, directories: string[]): void { + for (const directory of directories) { + logger.warn( + `No source maps found in "${directory}", so nothing was uploaded from it. ` + + 'This usually means source map generation is not enabled for this build output. ' + + 'Enable it so Sentry can un-minify stack traces from these files.', + ); + } +} diff --git a/packages/bundler-plugins/src/core/cli.ts b/packages/bundler-plugins/src/core/cli.ts index 2e12bd16b40a..5ecc6603d78d 100644 --- a/packages/bundler-plugins/src/core/cli.ts +++ b/packages/bundler-plugins/src/core/cli.ts @@ -120,14 +120,20 @@ export class SentryCliAdapter { * array; the new `sourcemap upload` command takes a single directory, so we upload each target * separately. A client is created per project because project selection is bound at client * creation time. + * + * Empty directories must not abort the loop: `@sentry/nextjs` uploads `.next/server` first, and + * a server build without source maps would otherwise skip every client directory after it. + * + * @returns The directories that had no source maps to upload. */ - public async uploadSourcemaps(name: string, targets: UploadTarget[]): Promise { + public async uploadSourcemaps(name: string, targets: UploadTarget[]): Promise { const projects = getProjects(this.#options.project) ?? [undefined]; + const emptyDirectories = new Set(); for (const project of projects) { const sdk = this.#createClient(project); for (const target of targets) { - await sdk.sourcemap.upload({ + const result = (await sdk.sourcemap.upload({ directory: target.directory, release: name, dist: target.dist ?? this.#options.release.dist, @@ -135,9 +141,15 @@ export class SentryCliAdapter { ignore: serializeIgnore(target.ignore), ignoreFile: target.ignoreFile, urlPrefix: target.urlPrefix, - }); + allowEmpty: true, + })) as { filesUploaded?: number } | undefined; + if (result?.filesUploaded === 0) { + emptyDirectories.add(target.directory); + } } } + + return [...emptyDirectories]; } /** Inject debug IDs into the given build artifacts. */ diff --git a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts index e5105d55f4f8..e2f82a2e1228 100644 --- a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts +++ b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts @@ -345,6 +345,50 @@ describe('createSentryBuildPluginManager', () => { expect(mockPrepareBundleForDebugIdUpload).not.toHaveBeenCalled(); }); + it('keeps uploading the other directories when one has no source maps', async () => { + mockSourcemapUpload.mockResolvedValueOnce({ filesUploaded: 0 }).mockResolvedValueOnce({ filesUploaded: 4 }); + + const manager = createSentryBuildPluginManager( + { authToken: 't', org: 'o', project: 'p', release: { name: 'some-release-name' } }, + { buildTool: 'webpack', loggerPrefix: '[sentry-webpack-plugin]' }, + ); + const warnSpy = vi.spyOn(manager.logger, 'warn'); + const infoSpy = vi.spyOn(manager.logger, 'info'); + + const result = await manager.uploadSourcemaps(['.next/server', '.next/static/chunks'], { + prepareArtifacts: false, + }); + + expect(mockSourcemapUpload).toHaveBeenCalledTimes(2); + expect(mockSourcemapUpload).toHaveBeenCalledWith( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ directory: '.next/server', allowEmpty: true }), + ); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No source maps found in ".next/server"')); + expect(infoSpy).toHaveBeenCalledWith('Successfully uploaded source maps to Sentry'); + expect(result).toEqual({ uploaded: true }); + + vi.restoreAllMocks(); + }); + + it('does not report success when no directory has source maps', async () => { + mockSourcemapUpload.mockResolvedValue({ filesUploaded: 0 }); + + const manager = createSentryBuildPluginManager( + { authToken: 't', org: 'o', project: 'p', release: { name: 'some-release-name' } }, + { buildTool: 'webpack', loggerPrefix: '[sentry-webpack-plugin]' }, + ); + const infoSpy = vi.spyOn(manager.logger, 'info'); + + const result = await manager.uploadSourcemaps(['.next/server'], { prepareArtifacts: false }); + + expect(infoSpy).not.toHaveBeenCalledWith('Successfully uploaded source maps to Sentry'); + expect(result).toEqual({ uploaded: false }); + + vi.restoreAllMocks(); + }); + it('exits early when assets is an empty array', async () => { const manager = createSentryBuildPluginManager( { @@ -419,6 +463,7 @@ describe('createSentryBuildPluginManager', () => { ignore: undefined, ignoreFile: undefined, urlPrefix: undefined, + allowEmpty: true, }); }); diff --git a/packages/bundler-plugins/test/core/cli-contract.test.ts b/packages/bundler-plugins/test/core/cli-contract.test.ts index f6c1a1d4c50d..51ad7ab86ecd 100644 --- a/packages/bundler-plugins/test/core/cli-contract.test.ts +++ b/packages/bundler-plugins/test/core/cli-contract.test.ts @@ -195,6 +195,21 @@ describe('SentryCliAdapter against the real CLI', () => { expect(requestsSince(before)).toContain('POST /api/0/organizations/test-org/artifactbundle/assemble/'); }); + it('skips a directory without source maps and still uploads the next one', async () => { + const emptyDir = join(workDir, 'server'); + mkdirSync(emptyDir); + writeFileSync(join(emptyDir, 'page.js'), 'console.log("no map");\n'); + const before = recordedRequests(); + + const emptyDirectories = await createAdapter().uploadSourcemaps('1.0.0', [ + { directory: emptyDir }, + { directory: buildDir }, + ]); + + expect(emptyDirectories).toEqual([emptyDir]); + expect(requestsSince(before)).toContain('POST /api/0/organizations/test-org/artifactbundle/assemble/'); + }); + it('resolves the server url, or gives up quietly', async () => { const adapter = createAdapter();