Skip to content
Closed
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
180 changes: 142 additions & 38 deletions packages/bundler-plugins/src/core/build-plugin-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ import {
arrayify,
getProjects,
getTurborepoEnvPassthroughWarning,
runWithConcurrency,
serializeIgnoreOptions,
stripQueryAndHashFromPath,
} from './utils';
import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from './debug-id-upload';
import {
defaultRewriteSourcesHook,
prepareBundleForDebugIdUpload,
type SourceMapStampResult,
stampDebugIdOnEmittedSourceMap,
} from './debug-id-upload';
import { globFiles } from './glob';
import { LIB_VERSION } from './version';

Expand All @@ -27,6 +33,10 @@ import { LIB_VERSION } from './version';
// for client, server, and edge). Keyed by release name.
const _deployedReleases = new Set<string>();

// Debug ID work reads whole bundles into memory, so it is spread over a fixed number of workers
// rather than being done all at once.
const DEBUG_ID_WORKER_COUNT = 16;

/** @internal Exported for testing only. */
export function _resetDeployedReleasesForTesting(): void {
_deployedReleases.clear();
Expand Down Expand Up @@ -88,6 +98,15 @@ export type SentryBuildPluginManager = {
*/
uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }): Promise<void>;

/**
* Writes the debug ID that was injected into each build artifact into the artifact's emitted source map.
*
* `uploadSourcemaps` does this on temporary copies it then uploads and deletes, so the emitted source maps
* never carry a debug ID. Call this instead of `uploadSourcemaps` when the upload is disabled via
* `sourcemaps.disable: 'disable-upload'` and the artifacts are meant to be uploaded manually later on.
*/
stampDebugIdsOnSourceMaps(buildArtifactPaths: string[]): Promise<void>;

/**
* Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option.
*/
Expand Down Expand Up @@ -180,6 +199,9 @@ export function createSentryBuildPluginManager(
uploadSourcemaps: async () => {
/* noop */
},
stampDebugIdsOnSourceMaps: async () => {
/* noop */
},
deleteArtifacts: async () => {
/* noop */
},
Expand Down Expand Up @@ -305,6 +327,65 @@ export function createSentryBuildPluginManager(
};
}

/**
* Resolves the JavaScript build artifacts that carry an injected debug ID, honoring the
* `sourcemaps.assets` and `sourcemaps.ignore` options.
*/
async function resolveDebugIdChunkFilePaths(buildArtifactPaths: string[]): Promise<string[]> {
const assets = options.sourcemaps?.assets;

let globAssets: string | string[];
if (assets) {
globAssets = assets;
} else {
logger.debug('No `sourcemaps.assets` option provided, falling back to detected build artifacts.');
globAssets = buildArtifactPaths;
}

const globResult = await startSpan({ name: 'glob', scope: sentryScope }, async () =>
globFiles(globAssets, { ignore: options.sourcemaps?.ignore }),
);

const debugIdChunkFilePaths = globResult.filter(debugIdChunkFilePath => {
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
});

// The order of the files output by glob() is not deterministic
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
debugIdChunkFilePaths.sort();

return debugIdChunkFilePaths;
}

/**
* Reports what the source map stamping actually accomplished.
*
* Stamping is best-effort per chunk, so "we found some chunks" is not the same as "the maps now carry a
* debug ID". Claiming the latter when nothing was written would point users at a manual upload that
* cannot possibly work.
*/
function logStampingOutcome(results: SourceMapStampResult[]): void {
const inlineSourceMapCount = results.filter(result => result === 'inlineSourceMap').length;
if (inlineSourceMapCount > 0) {
logger.warn(
`${inlineSourceMapCount} bundle(s) inline their source map. Stamping those would mean rewriting the bundle, so they were skipped and will not symbolicate. Emit source maps as separate files to get debug IDs.`,
);
}

const stampedCount = results.filter(result => result === 'stamped' || result === 'alreadyStamped').length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeated filters over stamp results

Low Severity

logStampingOutcome walks the same results array twice with separate .filter calls to count inline maps and stamped maps. The project review guidelines ask to avoid multiple loops over the same array and prefer a single classic for loop instead.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 1486446. Configure here.

if (stampedCount === 0) {
logger.warn(
"Didn't stamp a debug ID onto any source map. Set the `debug` option to see why the individual build artifacts were skipped.",
);
return;
}

logger.info(
`Stamped debug IDs onto ${stampedCount} source map(s). Upload them with \`sentry-cli sourcemaps upload --debug-id-reference\` to symbolicate stack traces.`,
);
}

/**
* Returns a Promise that resolves when all the currently active dependencies are freed again.
*
Expand Down Expand Up @@ -628,28 +709,7 @@ export function createSentryBuildPluginManager(
logger.info('Successfully uploaded source maps to Sentry');
} else {
// Prepare artifacts in temp folder before uploading
let globAssets: string | string[];
if (assets) {
globAssets = assets;
} else {
logger.debug(
'No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts.',
);
globAssets = buildArtifactPaths;
}

const globResult = await startSpan(
{ name: 'glob', scope: sentryScope },
async () => await globFiles(globAssets, { ignore: options.sourcemaps?.ignore }),
);

const debugIdChunkFilePaths = globResult.filter(debugIdChunkFilePath => {
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
});

// The order of the files output by glob() is not deterministic
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
debugIdChunkFilePaths.sort();
const debugIdChunkFilePaths = await resolveDebugIdChunkFilePaths(buildArtifactPaths);

if (debugIdChunkFilePaths.length === 0) {
logger.warn(
Expand All @@ -666,8 +726,6 @@ export function createSentryBuildPluginManager(

// Prepare into temp folder, then upload
await startSpan({ name: 'prepare-bundles', scope: sentryScope }, async prepBundlesSpan => {
// Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so
// instead we do it with a maximum of 16 concurrent workers
const preparationTasks = debugIdChunkFilePaths.map((chunkFilePath, chunkIndex) => async () => {
await prepareBundleForDebugIdUpload(
chunkFilePath,
Expand All @@ -678,20 +736,8 @@ export function createSentryBuildPluginManager(
options.sourcemaps?.resolveSourceMap,
);
});
const workers: Promise<void>[] = [];
const worker = async (): Promise<void> => {
while (preparationTasks.length > 0) {
const task = preparationTasks.shift();
if (task) {
await task();
}
}
};
for (let workerIndex = 0; workerIndex < 16; workerIndex++) {
workers.push(worker());
}

await Promise.all(workers);
await runWithConcurrency(preparationTasks, DEBUG_ID_WORKER_COUNT);

const files = await fs.promises.readdir(tmpUploadFolder);
const stats = files.map(file => fs.promises.stat(path.join(tmpUploadFolder, file)));
Expand Down Expand Up @@ -751,6 +797,64 @@ export function createSentryBuildPluginManager(
);
},

/**
* Writes the debug ID that was injected into each build artifact into the artifact's emitted source map.
*
* `uploadSourcemaps` stamps temporary copies it uploads and then deletes, which leaves the emitted source maps
* without a debug ID. That is fine as long as the plugin does the upload, but not when the user disabled it via
* `sourcemaps.disable: 'disable-upload'` to upload the artifacts manually later on - without a debug ID in the
* map, that upload cannot be matched to the bundles at runtime.
*
* Only the source maps are written to. The bundles stay byte-identical so that hashes computed during the build
* (e.g. for subresource integrity) remain valid.
*
* @param buildArtifactPaths - The paths of the build artifacts whose source maps should be stamped
*/
async stampDebugIdsOnSourceMaps(buildArtifactPaths: string[]) {
if (isDevMode) {
logger.debug('Running in development mode. Will not stamp debug IDs onto source maps.');
return;
}

const assets = options.sourcemaps?.assets;
if (Array.isArray(assets) && assets.length === 0) {
logger.debug('Empty `sourcemaps.assets` option provided. Will not stamp debug IDs onto source maps.');
return;
}

await startSpan({ name: 'debug-id-sourcemap-stamping', scope: sentryScope, forceTransaction: true }, async () => {
const freeStampingDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();

try {
const debugIdChunkFilePaths = await resolveDebugIdChunkFilePaths(buildArtifactPaths);

if (debugIdChunkFilePaths.length === 0) {
logger.warn(
"Didn't find any matching sources to stamp with debug IDs. Please check the `sourcemaps.assets` option.",
);
return;
}

const results = await startSpan({ name: 'stamp-source-maps', scope: sentryScope }, async () => {
const stampingTasks = debugIdChunkFilePaths.map(
chunkFilePath => () =>
stampDebugIdOnEmittedSourceMap(chunkFilePath, logger, options.sourcemaps?.resolveSourceMap),
);

return runWithConcurrency(stampingTasks, DEBUG_ID_WORKER_COUNT);
});

logStampingOutcome(results);
} catch (e) {
sentryScope.captureException('Error in "debugIdStampingPlugin" writeBundle hook');
handleRecoverableError(e, false);
} finally {
freeStampingDependencyOnBuildArtifacts();
await safeFlushTelemetry(sentryClient);
}
});
},

/**
* Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option.
*/
Expand Down
Loading
Loading