Skip to content
Open
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
125 changes: 122 additions & 3 deletions packages/bundler-plugins/src/core/debug-id-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,127 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string
}
}

function setDebugIdOnSourceMap(map: Record<string, unknown>, debugId: string): void {
// For now we write both fields until we know what will become the standard - if ever.
map['debug_id'] = debugId;
map['debugId'] = debugId;
}

export type StampedArtifacts = {
bundleSource: string;
/** `undefined` when the bundle has no separate source map (i.e. the map is inlined). */
sourceMapSource: string | undefined;
};

function parseSourceMap(sourceMapSource: string): Record<string, unknown> | undefined {
let map: unknown;
try {
map = JSON.parse(sourceMapSource);
} catch {
return undefined;
}

return map && typeof map === 'object' ? (map as Record<string, unknown>) : undefined;
}

/**
* Stamps the debug ID injected into `bundleSource` into the bundle (as `//# debugId=` comment) and its
* source map (as `debug_id`/`debugId` fields).
*
* This exists for `sourcemaps.disable: "disable-upload"`: the regular upload path only stamps
* temporary copies of the artifacts (see `prepareBundleForDebugIdUpload`), so without this the
* emitted artifacts would carry no debug ID and a later manual upload could not match them.
* Callers must apply the result inside the bundler's asset pipeline (or, for bundlers without one,
* before the build resolves) so that integrity hashes computed by later build steps include it.
*
* Pass `sourceMapSource: undefined` when the bundle has no separate source map. A bundle with an
* inlined map still gets the comment, which is all the CLI and Symbolicator read the debug ID from.
*
* @returns the stamped artifacts, or `undefined` for bundles without a debug ID, without any source
* map, or with an unparseable map.
*/
export function stampDebugId(bundleSource: string, sourceMapSource: string | undefined): StampedArtifacts | undefined {
const debugId = determineDebugIdFromBundleSource(bundleSource);
if (debugId === undefined) {
return undefined;
}

if (sourceMapSource === undefined) {
if (!bundleHasInlineSourceMap(bundleSource)) {
return undefined;
}

return { bundleSource: addDebugIdToBundleSource(bundleSource, debugId), sourceMapSource: undefined };
}

const map = parseSourceMap(sourceMapSource);
if (!map) {
return undefined;
}

setDebugIdOnSourceMap(map, debugId);

return {
bundleSource: addDebugIdToBundleSource(bundleSource, debugId),
sourceMapSource: JSON.stringify(map),
};
}

/**
* Stamps the debug ID of an emitted bundle into the bundle and its source map on disk.
*
* Used by bundlers that offer no hook to modify assets before they are written (esbuild).
*/
export async function addDebugIdToEmittedArtifacts(
bundleFilePath: string,
logger: Logger,
resolveSourceMapHook: ResolveSourceMapHook | undefined,
): Promise<void> {
let bundleSource: string;
try {
bundleSource = await fs.promises.readFile(bundleFilePath, 'utf8');
} catch (e) {
logger.error(`Could not read bundle to stamp debug ID: ${bundleFilePath}`, e);
return;
}

const sourceMapPath = await determineSourceMapPathFromBundle(
bundleFilePath,
bundleSource,
logger,
resolveSourceMapHook,
);

let sourceMapSource: string | undefined;
if (sourceMapPath) {
try {
sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8');
} catch (e) {
logger.error(`Could not read source map to stamp debug ID: ${sourceMapPath}`, e);
return;
}
}

const stamped = stampDebugId(bundleSource, sourceMapSource);
if (!stamped) {
logger.debug(
`Could not stamp debug ID (no debug ID in bundle, no source map, or invalid source map): ${bundleFilePath}`,
);
return;
}

const writes = [fs.promises.writeFile(bundleFilePath, stamped.bundleSource, 'utf8')];
if (sourceMapPath && stamped.sourceMapSource !== undefined) {
writes.push(fs.promises.writeFile(sourceMapPath, stamped.sourceMapSource, 'utf8'));
}

try {
await Promise.all(writes);
} catch (e) {
logger.error(`Could not write debug ID into build artifacts: ${bundleFilePath}`, e);
}
}

/**
* Whether the bundle carries its source map inlined as `sourceMappingURL=data:`
* URI, rather than referencing a separate `.map` file. Such bundles must still
Expand Down Expand Up @@ -223,9 +344,7 @@ async function prepareSourceMapForDebugIdUpload(
let map: Record<string, unknown>;
try {
map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown };
// For now we write both fields until we know what will become the standard - if ever.
map['debug_id'] = debugId;
map['debugId'] = debugId;
setDebugIdOnSourceMap(map, debugId);
} catch {
logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/bundler-plugins/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,4 @@ export {
generateModuleMetadataInjectorCode,
} from './utils';
export { createSentryBuildPluginManager } from './build-plugin-manager';
export { createDebugIdUploadFunction } from './debug-id-upload';
export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload';
9 changes: 7 additions & 2 deletions packages/bundler-plugins/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,13 @@ export interface Options {
/**
* Disables all functionality related to sourcemaps if set to `true`.
*
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will inject debug IDs into the build artifacts.
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time.
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will still inject debug IDs
* into the build artifacts: the bundles receive the runtime debug ID snippet and a `//# debugId=` comment, and the
* emitted source maps receive the matching `debug_id` field. This happens inside the bundler's build pipeline, so
* hashes computed by later build steps (e.g. for subresource integrity) include the debug IDs.
*
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time, e.g. with
* `sentry sourcemap upload`. The artifacts already carry their debug IDs, so no `inject` step is needed.
*
* @default false
*/
Expand Down
29 changes: 27 additions & 2 deletions packages/bundler-plugins/src/esbuild/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
getDebugIdSnippet,
createDebugIdUploadFunction,
CodeInjection,
addDebugIdToEmittedArtifacts,
isJsFile,
} from '../core';
import * as path from 'node:path';
import { createRequire } from 'node:module';
Expand Down Expand Up @@ -51,6 +53,8 @@ interface EsbuildInitialOptions {
inject?: string[];
metafile?: boolean;
define?: Record<string, string>;
write?: boolean;
absWorkingDir?: string;
}

interface EsbuildPluginBuild {
Expand Down Expand Up @@ -283,9 +287,30 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
try {
await sentryBuildPluginManager.createRelease();

if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') {
if (sourcemapsEnabled) {
const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : [];
await upload(buildArtifacts);

if (options.sourcemaps?.disable !== 'disable-upload') {
await upload(buildArtifacts);
} else if (initialOptions.write === false) {
logger.debug('Build output is not written to disk. Skipping debug ID injection into build artifacts.');
} else {
// The upload routine (which stamps debug IDs into temp copies of the artifacts) is
// skipped with `disable-upload`. esbuild has no hook to modify outputs before they are
// written, so the emitted artifacts get stamped on disk instead.
const outputDir = initialOptions.absWorkingDir ?? process.cwd();
await Promise.all(
buildArtifacts
.filter(isJsFile)
.map(bundle =>
addDebugIdToEmittedArtifacts(
path.resolve(outputDir, bundle),
logger,
options.sourcemaps?.resolveSourceMap,
),
),
);
}
}
} finally {
freeGlobalDependencyOnBuildArtifacts();
Expand Down
67 changes: 49 additions & 18 deletions packages/bundler-plugins/src/rollup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createComponentNameAnnotateHooks,
replaceBooleanFlagsInCode,
CodeInjection,
stampDebugId,
} from '../core';
import type {
ComponentAnnotationTransformMeta,
Expand All @@ -28,6 +29,13 @@ import { createRequire } from 'node:module';
// because `rollup` is an optional dependency.
type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined;

// The subset of Rollup's `OutputBundle` the stamping hook reads.
type OutputBundle = Record<
string,
| { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null }
| { type: 'asset'; fileName: string; source: string | Uint8Array }
>;

type ViteModule = {
parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise<unknown>;
};
Expand Down Expand Up @@ -284,6 +292,38 @@ export function _rollupPluginInternal(
};
}

/**
* Stamps debug IDs into the emitted chunks and source maps.
*
* `disable-upload` skips the upload routine (which stamps debug IDs into temp copies), so the emitted
* artifacts get stamped here instead. Not in `renderChunk`: minifiers running after it would strip the
* comment. Rollup computes `[hash]` file names before this hook, so only plugins that hash the final
* assets afterwards (e.g. subresource integrity) see the stamped content.
*/
function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void {
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk' || !isJsFile(output.fileName)) {
continue;
}

const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`];
const sourceMapSource =
sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string'
? sourceMapAsset.source
: undefined;

const stamped = stampDebugId(output.code, sourceMapSource);
if (!stamped) {
continue;
}

output.code = stamped.bundleSource;
if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') {
sourceMapAsset.source = stamped.sourceMapSource;
}
}
}

async function writeBundle(
outputOptions: { dir?: string; file?: string },
bundle: { [fileName: string]: unknown },
Expand Down Expand Up @@ -318,29 +358,20 @@ export function _rollupPluginInternal(
}

const name = `sentry-${buildTool}-plugin`;

if (shouldTransform) {
const transformHook =
buildTool === 'vite'
? {
filter: { id: JS_MODULE_ID_FILTER },
handler: transform,
}
: transform;

return {
name,
buildStart,
transform: transformHook,
renderChunk,
writeBundle,
};
}
const transformHook =
buildTool === 'vite'
? {
filter: { id: JS_MODULE_ID_FILTER },
handler: transform,
}
: transform;

return {
name,
buildStart,
...(shouldTransform ? { transform: transformHook } : {}),
renderChunk,
...(options.sourcemaps?.disable === 'disable-upload' ? { generateBundle } : {}),
writeBundle,
};
}
Expand Down
Loading
Loading