From 84732bc6bdc50cf7251808b8b7b7c5d561a052d0 Mon Sep 17 00:00:00 2001 From: sauce-abhisheks Date: Thu, 26 Mar 2026 17:32:17 +0530 Subject: [PATCH 1/2] sourcemap-tools, rollup-plugin, webpack-plugin: add assetErrorBehavior option to skip or warn on failed assets --- tools/rollup-plugin/src/index.ts | 2 + .../commands/processAndUploadAssetsCommand.ts | 94 ++++++- .../processAndUploadAssetsCommand.spec.ts | 256 ++++++++++++++++++ tools/webpack-plugin/src/BacktracePlugin.ts | 2 + 4 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts diff --git a/tools/rollup-plugin/src/index.ts b/tools/rollup-plugin/src/index.ts index b0cb7815..6ac0af6d 100644 --- a/tools/rollup-plugin/src/index.ts +++ b/tools/rollup-plugin/src/index.ts @@ -22,6 +22,8 @@ export function BacktracePlugin(options?: BacktracePluginOptions): Plugin { afterWrite: (asset) => debug(`[${asset.asset.name}] wrote source and sourcemap to file`), assetFinished: (asset) => info(`[${asset.asset.name}] asset processed successfully`), assetError: (asset) => this.warn(`[${asset.asset.name}] ${asset.error}`), + assetSkipped: (asset) => debug(`[${asset.asset.name}] skipped: ${asset.error}`), + processingSummary: (message) => info(message), beforeUpload: (paths) => info(`uploading ${paths.length} sourcemaps...`), afterUpload: (result) => info(`sourcemaps uploaded to Backtrace: ${result.rxid}`), diff --git a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts index 70aae2a0..c5c63bbd 100644 --- a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts +++ b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts @@ -7,7 +7,7 @@ import { inspect, mapAsync, pass } from '../helpers/common'; import { flow, pipe } from '../helpers/flow'; import { Asset, AssetWithContent } from '../models/Asset'; import { ProcessAssetError, ProcessAssetResult } from '../models/ProcessAssetResult'; -import { R, Result } from '../models/Result'; +import { R, Result, ResultOk } from '../models/Result'; import { createArchive, finalizeArchive } from './archiveSourceMaps'; import { loadSourceMap, stripSourcesContent } from './loadSourceMaps'; import { processAsset } from './processAsset'; @@ -22,6 +22,15 @@ interface BacktracePluginUploadOptions { readonly includeSources: boolean; } +/** + * Determines what happens when an individual asset fails to process. + * + * - `'exit'` — abort the entire process and upload (default). + * - `'skip'` — silently skip the failed asset; successfully processed assets are still uploaded. + * - `'warn'` — log the failure via the `assetError` callback and continue uploading the rest. + */ +export type AssetErrorBehavior = 'exit' | 'skip' | 'warn'; + export interface BacktracePluginOptions { /** * Upload URL for uploading sourcemap files. @@ -35,6 +44,20 @@ export interface BacktracePluginOptions { * Additional upload options. */ readonly uploadOptions?: SymbolUploaderOptions & BacktracePluginUploadOptions; + + /** + * What to do when an individual asset fails to process (e.g. a missing sourcemap file). + * + * - `'exit'` — abort the entire process and upload. No sourcemaps will be uploaded. (default) + * - `'skip'` — silently skip the failed asset. Successfully processed assets are still uploaded. + * - `'warn'` — report the failure via the `assetError` callback and continue uploading the rest. + * + * In large projects where some JS files may not have corresponding sourcemaps, set this to `'skip'` or `'warn'` + * to ensure the remaining sourcemaps are still uploaded. + * + * @default 'exit' + */ + readonly assetErrorBehavior?: AssetErrorBehavior; } interface ProcessResult { @@ -55,6 +78,8 @@ export interface ProcessAndUploadAssetsCommandOptions { beforeUpload?(assets: AssetWithContent[]): unknown; afterUpload?(result: UploadResult): unknown; assetError?(error: ProcessAssetError): unknown; + assetSkipped?(error: ProcessAssetError): unknown; + processingSummary?(message: string): unknown; uploadError?(error: string): unknown; } @@ -84,28 +109,73 @@ export function processAndUploadAssetsCommand( R.map(writeAsset), R.map(options?.afterWrite ? inspect(options.afterWrite) : pass), R.map(options?.assetFinished ? inspect(options.assetFinished) : pass), - R.mapErr(options?.assetError ? inspect(options.assetError) : pass), ), ), ); - const assetsResult = R.flatMap(assetResults); - if (assetsResult.isErr()) { - const result: ProcessResult = { assetResults }; - options?.afterAll && options.afterAll(result); - return result; + const assetErrorBehavior = pluginOptions.assetErrorBehavior ?? 'exit'; + const failedAssets = assetResults.filter((r) => r.isErr()); + const successfulAssets = assetResults.filter((r): r is ResultOk => r.isOk()); + + if (failedAssets.length > 0) { + const summary = + `${failedAssets.length} of ${assets.length} asset(s) failed to process, ` + + `${successfulAssets.length} succeeded`; + + switch (assetErrorBehavior) { + case 'exit': + // Report each failure via assetError, then throw to fail the build. + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetError && options.assetError(failed.data); + } + } + options?.afterAll && options.afterAll({ assetResults }); + throw new Error( + `Backtrace: ${summary}. ` + + `Upload aborted. Set assetErrorBehavior to 'skip' or 'warn' to upload the remaining assets.`, + ); + + case 'warn': + // Report each failure via assetError and continue uploading the rest. + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetError && options.assetError(failed.data); + } + } + options?.processingSummary && + options.processingSummary( + `${summary}. Continuing with ${successfulAssets.length} successfully processed asset(s).`, + ); + break; + + case 'skip': + // Silently skip — report via assetSkipped (not assetError). + for (const failed of failedAssets) { + if (failed.isErr()) { + options?.assetSkipped && options.assetSkipped(failed.data); + } + } + options?.processingSummary && + options.processingSummary( + `${summary}. Skipped failed asset(s), continuing with ${successfulAssets.length} successfully processed asset(s).`, + ); + break; + } } - if (!uploadCommand) { + if (!uploadCommand || successfulAssets.length === 0) { const result: ProcessResult = { assetResults }; options?.afterAll && options.afterAll(result); return result; } - const sourceMapAssets = assetsResult.data.map((r) => ({ - name: path.basename(r.result.sourceMapPath), - path: r.result.sourceMapPath, - })); + const sourceMapAssets = successfulAssets + .map((r) => r.data) + .map((r) => ({ + name: path.basename(r.result.sourceMapPath), + path: r.result.sourceMapPath, + })); const includeSources = pluginOptions?.uploadOptions?.includeSources; diff --git a/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts new file mode 100644 index 00000000..2c3e40df --- /dev/null +++ b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts @@ -0,0 +1,256 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + processAndUploadAssetsCommand, + BacktracePluginOptions, + ProcessAndUploadAssetsCommandOptions, +} from '../../src/commands/processAndUploadAssetsCommand'; +import { Asset } from '../../src/models/Asset'; + +describe('processAndUploadAssetsCommand', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bt-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function createSourceFile(name: string, content?: string): string { + const source = content ?? `function ${name.replace(/[^a-zA-Z]/g, '')}(){console.log("hello")}`; + const filePath = path.join(tmpDir, name); + fs.writeFileSync(filePath, source); + return filePath; + } + + function createSourceMapFile(sourceName: string, sourceContent?: string): string { + const source = sourceContent ?? `function ${sourceName.replace(/[^a-zA-Z]/g, '')}(){console.log("hello")}`; + const sourceMap = { + version: 3, + file: sourceName, + sources: [sourceName], + names: [], + mappings: 'AAAA', + sourcesContent: [source], + }; + const mapPath = path.join(tmpDir, `${sourceName}.map`); + fs.writeFileSync(mapPath, JSON.stringify(sourceMap)); + + // Add sourceMappingURL to source file + const sourcePath = path.join(tmpDir, sourceName); + if (fs.existsSync(sourcePath)) { + const existing = fs.readFileSync(sourcePath, 'utf-8'); + fs.writeFileSync(sourcePath, existing + `\n//# sourceMappingURL=${sourceName}.map`); + } + + return mapPath; + } + + function createAsset(name: string): Asset { + return { name, path: path.join(tmpDir, name) }; + } + + describe('assetErrorBehavior', () => { + it('should throw and fail the build when behavior is exit and an asset fails', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap for this one + + const assetError = jest.fn(); + const afterUpload = jest.fn(); + const afterAll = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'exit', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + afterUpload, + afterAll, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + + await expect(command([createAsset('good.js'), createAsset('bad.js')])).rejects.toThrow( + /1 of 2 asset\(s\) failed to process, 1 succeeded.*Upload aborted/, + ); + + // assetError callback should have been called for the bad asset + expect(assetError).toHaveBeenCalled(); + // Upload should NOT have happened + expect(afterUpload).not.toHaveBeenCalled(); + // afterAll should have been called before throwing + expect(afterAll).toHaveBeenCalled(); + }); + + it('should throw by default (no assetErrorBehavior set) when an asset fails', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + // assetErrorBehavior not set — defaults to 'exit' + }; + + const command = processAndUploadAssetsCommand(options); + + await expect(command([createAsset('good.js'), createAsset('bad.js')])).rejects.toThrow( + /asset\(s\) failed to process.*Upload aborted/, + ); + }); + + it('should use assetSkipped (not assetError) and upload successes when behavior is skip', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap for this one + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + // skip should NOT call assetError — it's silent + expect(assetError).not.toHaveBeenCalled(); + // skip should call assetSkipped instead + expect(assetSkipped).toHaveBeenCalledTimes(1); + // The good asset should have been processed successfully + expect(assetFinished).toHaveBeenCalledTimes(1); + // Upload should have been attempted (uploadResult should be defined) + expect(result.uploadResult).toBeDefined(); + }); + + it('should use assetError (not assetSkipped) and upload successes when behavior is warn', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); // No sourcemap + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'warn', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + // warn should call assetError (visible warning) + expect(assetError).toHaveBeenCalledTimes(1); + // warn should NOT call assetSkipped + expect(assetSkipped).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(1); + expect(result.uploadResult).toBeDefined(); + }); + + it('should not attempt upload when all assets fail with behavior skip', async () => { + createSourceFile('bad1.js'); // No sourcemap + createSourceFile('bad2.js'); // No sourcemap + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const afterUpload = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + afterUpload, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('bad1.js'), createAsset('bad2.js')]); + + // skip mode: assetSkipped called, not assetError + expect(assetError).not.toHaveBeenCalled(); + expect(assetSkipped).toHaveBeenCalledTimes(2); + expect(afterUpload).not.toHaveBeenCalled(); + expect(result.uploadResult).toBeUndefined(); + }); + + it('should process all assets successfully when none fail', async () => { + createSourceFile('a.js'); + createSourceMapFile('a.js'); + createSourceFile('b.js'); + createSourceMapFile('b.js'); + + const assetError = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('a.js'), createAsset('b.js')]); + + expect(assetError).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(2); + expect(result.uploadResult).toBeDefined(); + }); + + it('should still process without upload when uploadUrl is not set', async () => { + createSourceFile('good.js'); + createSourceMapFile('good.js'); + createSourceFile('bad.js'); + + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + + const options: BacktracePluginOptions = { + assetErrorBehavior: 'skip', + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, + }; + + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); + + expect(assetError).not.toHaveBeenCalled(); + expect(assetSkipped).toHaveBeenCalledTimes(1); + expect(assetFinished).toHaveBeenCalledTimes(1); + expect(result.uploadResult).toBeUndefined(); + }); + }); +}); diff --git a/tools/webpack-plugin/src/BacktracePlugin.ts b/tools/webpack-plugin/src/BacktracePlugin.ts index 412584d5..72da2c42 100644 --- a/tools/webpack-plugin/src/BacktracePlugin.ts +++ b/tools/webpack-plugin/src/BacktracePlugin.ts @@ -28,6 +28,8 @@ export class BacktracePlugin implements WebpackPluginInstance { afterWrite: (asset) => logger.log(`[${asset.asset.name}] wrote source and sourcemap to file`), assetFinished: (asset) => logger.info(`[${asset.asset.name}] asset processed successfully`), assetError: (asset) => logger.error(`[${asset.asset.name}] ${asset.error}`), + assetSkipped: (asset) => logger.debug(`[${asset.asset.name}] skipped: ${asset.error}`), + processingSummary: (message) => logger.info(message), beforeUpload: (paths) => logger.log(`uploading ${paths.length} sourcemaps...`), afterUpload: (result) => logger.info(`sourcemaps uploaded to Backtrace: ${result.rxid}`), From d6b019d204c68bf0f7bb1fb6d6d59da90544597b Mon Sep 17 00:00:00 2001 From: sauce-abhisheks Date: Tue, 31 Mar 2026 18:43:52 +0530 Subject: [PATCH 2/2] sourcemap-tools: make warn the default assetErrorBehavior --- .../commands/processAndUploadAssetsCommand.ts | 11 +++----- .../processAndUploadAssetsCommand.spec.ts | 26 ++++++++++++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts index c5c63bbd..cb7e2ec8 100644 --- a/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts +++ b/tools/sourcemap-tools/src/commands/processAndUploadAssetsCommand.ts @@ -48,14 +48,11 @@ export interface BacktracePluginOptions { /** * What to do when an individual asset fails to process (e.g. a missing sourcemap file). * - * - `'exit'` — abort the entire process and upload. No sourcemaps will be uploaded. (default) + * - `'warn'` — report the failure via the `assetError` callback and continue uploading the rest. (default) * - `'skip'` — silently skip the failed asset. Successfully processed assets are still uploaded. - * - `'warn'` — report the failure via the `assetError` callback and continue uploading the rest. + * - `'exit'` — abort the entire process and upload. No sourcemaps will be uploaded. * - * In large projects where some JS files may not have corresponding sourcemaps, set this to `'skip'` or `'warn'` - * to ensure the remaining sourcemaps are still uploaded. - * - * @default 'exit' + * @default 'warn' */ readonly assetErrorBehavior?: AssetErrorBehavior; } @@ -113,7 +110,7 @@ export function processAndUploadAssetsCommand( ), ); - const assetErrorBehavior = pluginOptions.assetErrorBehavior ?? 'exit'; + const assetErrorBehavior = pluginOptions.assetErrorBehavior ?? 'warn'; const failedAssets = assetResults.filter((r) => r.isErr()); const successfulAssets = assetResults.filter((r): r is ResultOk => r.isOk()); diff --git a/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts index 2c3e40df..b413612d 100644 --- a/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts +++ b/tools/sourcemap-tools/tests/commands/processAndUploadAssetsCommand.spec.ts @@ -88,21 +88,35 @@ describe('processAndUploadAssetsCommand', () => { expect(afterAll).toHaveBeenCalled(); }); - it('should throw by default (no assetErrorBehavior set) when an asset fails', async () => { + it('should default to warn behavior (no throw, upload rest) when assetErrorBehavior is not set', async () => { createSourceFile('good.js'); createSourceMapFile('good.js'); createSourceFile('bad.js'); // No sourcemap + const assetError = jest.fn(); + const assetSkipped = jest.fn(); + const assetFinished = jest.fn(); + const options: BacktracePluginOptions = { uploadUrl: 'https://submit.backtrace.io/test/token/sourcemap', - // assetErrorBehavior not set — defaults to 'exit' + // assetErrorBehavior not set — defaults to 'warn' + }; + + const callbacks: ProcessAndUploadAssetsCommandOptions = { + assetError, + assetSkipped, + assetFinished, }; - const command = processAndUploadAssetsCommand(options); + const command = processAndUploadAssetsCommand(options, callbacks); + const result = await command([createAsset('good.js'), createAsset('bad.js')]); - await expect(command([createAsset('good.js'), createAsset('bad.js')])).rejects.toThrow( - /asset\(s\) failed to process.*Upload aborted/, - ); + // Default is warn: assetError called, not assetSkipped + expect(assetError).toHaveBeenCalledTimes(1); + expect(assetSkipped).not.toHaveBeenCalled(); + expect(assetFinished).toHaveBeenCalledTimes(1); + // Upload should still happen + expect(result.uploadResult).toBeDefined(); }); it('should use assetSkipped (not assetError) and upload successes when behavior is skip', async () => {