diff --git a/packages/bundler-plugins/src/core/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts index 1a09be2df2fd..8041e8c99d0f 100644 --- a/packages/bundler-plugins/src/core/debug-id-upload.ts +++ b/packages/bundler-plugins/src/core/debug-id-upload.ts @@ -119,6 +119,127 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string } } +function setDebugIdOnSourceMap(map: Record, 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 | undefined { + let map: unknown; + try { + map = JSON.parse(sourceMapSource); + } catch { + return undefined; + } + + return map && typeof map === 'object' ? (map as Record) : 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 { + 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 @@ -223,9 +344,7 @@ async function prepareSourceMapForDebugIdUpload( let map: Record; 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; diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index f361fdeb90d4..0386bf3e86ac 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -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'; diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index 405687596ec1..bd3761c12dca 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -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 */ diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index e4b1374729e3..f3bc0fc8c01e 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -6,6 +6,8 @@ import { getDebugIdSnippet, createDebugIdUploadFunction, CodeInjection, + addDebugIdToEmittedArtifacts, + isJsFile, } from '../core'; import * as path from 'node:path'; import { createRequire } from 'node:module'; @@ -51,6 +53,8 @@ interface EsbuildInitialOptions { inject?: string[]; metafile?: boolean; define?: Record; + write?: boolean; + absWorkingDir?: string; } interface EsbuildPluginBuild { @@ -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(); diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index a1ebcb98769d..238ff5c184af 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -13,6 +13,7 @@ import { createComponentNameAnnotateHooks, replaceBooleanFlagsInCode, CodeInjection, + stampDebugId, } from '../core'; import type { ComponentAnnotationTransformMeta, @@ -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; }; @@ -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 }, @@ -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, }; } diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts index d58f37aefab8..42c635e37bb2 100644 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ b/packages/bundler-plugins/src/webpack/webpack4and5.ts @@ -8,6 +8,8 @@ import { CodeInjection, getDebugIdSnippet, createDebugIdUploadFunction, + isJsFile, + stampDebugId, } from '../core/index'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -55,31 +57,20 @@ type UnsafeDefinePlugin = { new (options: any): unknown; }; -type WebpackModule = { - resource?: string; +type WebpackSource = { + source: () => string | Buffer; }; -type WebpackLoaderCallback = (err: Error | null, content?: string, sourceMap?: unknown) => void; - -type WebpackLoaderContext = { - callback: WebpackLoaderCallback; +type WebpackRawSource = { + new (source: string): WebpackSource; }; -type WebpackCompilationContext = { - compiler: { - webpack?: { - NormalModule?: { - getCompilationHooks: (compilation: WebpackCompilationContext) => { - loader: { - tap: (name: string, callback: (loaderContext: WebpackLoaderContext, module: WebpackModule) => void) => void; - }; - }; - }; - }; - }; - hooks: { - normalModuleLoader?: { - tap: (name: string, callback: (loaderContext: WebpackLoaderContext, module: WebpackModule) => void) => void; +type WebpackAsset = { + name: string; + source: WebpackSource; + info: { + related?: { + sourceMap?: string | string[]; }; }; }; @@ -94,7 +85,7 @@ type WebpackCompiler = { }; hooks: { thisCompilation: { - tap: (name: string, callback: (compilation: WebpackCompilationContext) => void) => void; + tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; }; afterEmit: { tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; @@ -106,6 +97,12 @@ type WebpackCompiler = { webpack?: { BannerPlugin?: UnsafeBannerPlugin; DefinePlugin?: UnsafeDefinePlugin; + Compilation?: { + PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; + }; + sources?: { + RawSource?: WebpackRawSource; + }; }; }; @@ -114,6 +111,9 @@ type WebpackCompilation = { path?: string; }; assets: Record; + getAssets: () => WebpackAsset[]; + getAsset: (name: string) => WebpackAsset | undefined; + updateAsset: (name: string, source: WebpackSource) => void; hooks: { processAssets: { tap: (options: { name: string; stage: number }, callback: () => void) => void; @@ -136,6 +136,35 @@ function getWebpackMajorVersion(): string | undefined { } } +/** + * Stamps each JS asset's debug ID into the asset itself and its source map asset. + * + * Runs after source maps have been generated, so the JS asset no longer needs to carry + * source map information and can be replaced with a plain `RawSource`. + */ +function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name)) { + continue; + } + + const bundleSource = asset.source.source().toString(); + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + const sourceMapAsset = compilation.getAsset(sourceMapName); + + const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); + if (!stamped) { + continue; + } + + compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); + if (stamped.sourceMapSource !== undefined) { + compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); + } + } +} + /** * The factory function accepts BannerPlugin and DefinePlugin classes in * order to avoid direct dependencies on webpack. @@ -245,6 +274,27 @@ export function sentryWebpackPluginFactory({ } } + // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped + // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. + if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { + const RawSource = compiler.webpack?.sources?.RawSource; + // Right after source map generation (and thus after minification, which would strip the comment), + // so later stages (real content hashing, subresource integrity) see the final assets. + const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; + + if (!RawSource) { + logger.warn( + 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', + ); + } else { + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { + addDebugIdsToAssets(compilation, RawSource); + }); + }); + } + } + // Add DefinePlugin for bundle size optimizations if (transformReplace && DefinePlugin) { compiler.options.plugins = compiler.options.plugins || []; diff --git a/packages/bundler-plugins/test/core/debug-id-upload.test.ts b/packages/bundler-plugins/test/core/debug-id-upload.test.ts index 6e1ac6940f29..dc20602b6dfd 100644 --- a/packages/bundler-plugins/test/core/debug-id-upload.test.ts +++ b/packages/bundler-plugins/test/core/debug-id-upload.test.ts @@ -2,10 +2,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { prepareBundleForDebugIdUpload } from '../../src/core/debug-id-upload'; +import { + addDebugIdToEmittedArtifacts, + prepareBundleForDebugIdUpload, + stampDebugId, +} from '../../src/core/debug-id-upload'; import type { RewriteSourcesHook } from '../../src/core/types'; import type { Logger } from '../../src/core'; +const debugIdSnippet = (debugId: string): string => + `;!function(){try{var e="undefined"!=typeof window?window:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`; + +const makeLogger = (): Logger => + ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as unknown as Logger; + describe('prepareBundleForDebugIdUpload', () => { let tmpDir: string; @@ -62,12 +72,7 @@ describe('prepareBundleForDebugIdUpload', () => { expect(capturedContexts[0]!.mapDir).toBe(bundleDir); }); - const debugIdSnippet = (debugId: string): string => - `;!function(){try{var e="undefined"!=typeof window?window:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`; - const noopRewriteHook: RewriteSourcesHook = source => source; - const makeLogger = (): Logger => - ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as unknown as Logger; it('does not write an upload artifact for a chunk that has no source map', async () => { const bundleDir = path.join(tmpDir, 'src'); @@ -106,3 +111,122 @@ describe('prepareBundleForDebugIdUpload', () => { expect(fs.readdirSync(uploadDir)).toEqual([`${debugId}-0.js`]); }); }); + +describe('stampDebugId', () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=bundle.js.map`; + const sourceMapSource = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: 'AAAA' }); + const inlineMap = Buffer.from(sourceMapSource).toString('base64'); + const inlineBundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; + + it('stamps the debug ID from the bundle into the source map and appends the spec comment to the bundle', () => { + const stamped = stampDebugId(bundleSource, sourceMapSource); + + expect(stamped?.bundleSource).toBe(`${bundleSource}\n//# debugId=${debugId}`); + expect(JSON.parse(stamped!.sourceMapSource!)).toEqual({ + version: 3, + sources: ['a.ts'], + mappings: 'AAAA', + debug_id: debugId, + debugId: debugId, + }); + }); + + it('replaces an existing spec comment instead of adding a second one', () => { + const stamped = stampDebugId(`${bundleSource}\n//# debugId=00000000-0000-0000-0000-000000000000`, sourceMapSource); + + expect(stamped?.bundleSource).toBe(`${bundleSource}\n//# debugId=${debugId}`); + }); + + it('stamps only the bundle when the source map is inlined', () => { + expect(stampDebugId(inlineBundleSource, undefined)).toEqual({ + bundleSource: `${inlineBundleSource}\n//# debugId=${debugId}`, + sourceMapSource: undefined, + }); + }); + + it('returns undefined for a bundle without a separate or inlined source map', () => { + expect(stampDebugId(`"use strict";\n${debugIdSnippet(debugId)}`, undefined)).toBeUndefined(); + }); + + it('returns undefined for a bundle that carries no debug ID', () => { + expect(stampDebugId('console.log(1);', sourceMapSource)).toBeUndefined(); + expect( + stampDebugId('console.log(1);\n//# sourceMappingURL=data:application/json;base64,e30=', undefined), + ).toBeUndefined(); + }); + + it('returns undefined when the source map is not valid JSON', () => { + expect(stampDebugId(bundleSource, '{not json')).toBeUndefined(); + }); + + it('returns undefined when the source map is not an object', () => { + expect(stampDebugId(bundleSource, '"a string"')).toBeUndefined(); + }); +}); + +describe('addDebugIdToEmittedArtifacts', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites the bundle and its source map on disk', async () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bundlePath = path.join(tmpDir, 'bundle.js'); + const mapPath = path.join(tmpDir, 'bundle.js.map'); + const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=bundle.js.map`; + fs.writeFileSync(bundlePath, bundleSource); + fs.writeFileSync(mapPath, JSON.stringify({ version: 3, sources: ['a.ts'], mappings: 'AAAA' })); + + await addDebugIdToEmittedArtifacts(bundlePath, makeLogger(), undefined); + + expect(fs.readFileSync(bundlePath, 'utf8')).toBe(`${bundleSource}\n//# debugId=${debugId}`); + expect(JSON.parse(fs.readFileSync(mapPath, 'utf8'))).toMatchObject({ debug_id: debugId, debugId: debugId }); + }); + + it('does nothing when the bundle has no source map', async () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bundlePath = path.join(tmpDir, 'bundle.js'); + const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}`; + fs.writeFileSync(bundlePath, bundleSource); + const logger = makeLogger(); + + await addDebugIdToEmittedArtifacts(bundlePath, logger, undefined); + + expect(fs.readFileSync(bundlePath, 'utf8')).toBe(bundleSource); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('stamps the bundle when the source map is inlined', async () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bundlePath = path.join(tmpDir, 'bundle.js'); + const inlineMap = Buffer.from(JSON.stringify({ version: 3, sources: ['a.ts'], mappings: 'AAAA' })).toString( + 'base64', + ); + const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; + fs.writeFileSync(bundlePath, bundleSource); + + await addDebugIdToEmittedArtifacts(bundlePath, makeLogger(), undefined); + + expect(fs.readFileSync(bundlePath, 'utf8')).toBe(`${bundleSource}\n//# debugId=${debugId}`); + }); + + it('leaves a bundle with an inlined source map but no debug ID untouched', async () => { + const bundlePath = path.join(tmpDir, 'bundle.js'); + const inlineMap = Buffer.from(JSON.stringify({ version: 3, sources: ['a.ts'], mappings: 'AAAA' })).toString( + 'base64', + ); + const bundleSource = `"use strict";\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; + fs.writeFileSync(bundlePath, bundleSource); + + await addDebugIdToEmittedArtifacts(bundlePath, makeLogger(), undefined); + + expect(fs.readFileSync(bundlePath, 'utf8')).toBe(bundleSource); + }); +}); diff --git a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts new file mode 100644 index 000000000000..ca077832169e --- /dev/null +++ b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as esbuild from 'esbuild'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryEsbuildPlugin } from '../../src/esbuild'; + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; + +describe('sourcemaps.disable: "disable-upload"', () => { + let tmpDir: string; + let entry: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-disable-upload-')); + entry = path.join(tmpDir, 'entry.js'); + fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stamps the emitted source map with the debug ID injected into the bundle', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await esbuild.build({ + entryPoints: [entry], + bundle: true, + sourcemap: true, + outdir: outDir, + absWorkingDir: tmpDir, + plugins: [sentryEsbuildPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + expect(bundle).toContain(`//# debugId=${debugId}`); + }); + + it('does not stamp emitted source maps when uploading is enabled', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await esbuild.build({ + entryPoints: [entry], + bundle: true, + sourcemap: true, + outdir: outDir, + absWorkingDir: tmpDir, + // No auth token, so the upload itself is skipped with a warning. + plugins: [sentryEsbuildPlugin({ telemetry: false })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + + expect(map).not.toHaveProperty('debug_id'); + expect(bundle).not.toContain('//# debugId='); + }); + + it('stamps the bundle when the source map is inlined into the bundle', async () => { + const outDir = path.join(tmpDir, 'dist'); + await esbuild.build({ + entryPoints: [entry], + bundle: true, + sourcemap: 'inline', + outdir: outDir, + absWorkingDir: tmpDir, + plugins: [sentryEsbuildPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(bundle).toContain(`//# debugId=${debugId}`); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/disable-upload.test.ts b/packages/bundler-plugins/test/rollup/disable-upload.test.ts new file mode 100644 index 000000000000..23b9db3cf5a4 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/disable-upload.test.ts @@ -0,0 +1,100 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import { rolldown } from 'rolldown'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; +import { sentryVitePlugin } from '../../src/vite'; + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; + +describe('sourcemaps.disable: "disable-upload"', () => { + let tmpDir: string; + let entry: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-disable-upload-')); + entry = path.join(tmpDir, 'entry.js'); + fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stamps the emitted source map with the debug ID injected into the chunk (rollup)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + await build.write({ dir: outDir, sourcemap: true }); + + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + const debugId = chunk.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + expect(chunk).toContain(`//# debugId=${debugId}`); + }); + + it('stamps the emitted source map with the debug ID injected into the chunk (rolldown / vite)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rolldown({ + input: entry, + plugins: sentryVitePlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } }), + }); + await build.write({ dir: outDir, sourcemap: true }); + + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + const debugId = chunk.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + expect(chunk).toContain(`//# debugId=${debugId}`); + }); + + it('stamps a hidden source map that the chunk does not reference', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + const debugId = chunk.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(chunk).not.toContain('sourceMappingURL'); + expect(map.debug_id).toBe(debugId); + expect(chunk).toContain(`//# debugId=${debugId}`); + }); + + it('does not register the generateBundle hook when uploading is enabled', () => { + const [plugin] = sentryRollupPlugin({ telemetry: false }); + + expect(plugin).not.toHaveProperty('generateBundle'); + }); + + it('stamps the chunk when the source map is inlined into the chunk', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + await build.write({ dir: outDir, sourcemap: 'inline' }); + + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const debugId = chunk.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(chunk).toContain(`//# debugId=${debugId}`); + }); +}); diff --git a/packages/bundler-plugins/test/webpack/disable-upload.test.ts b/packages/bundler-plugins/test/webpack/disable-upload.test.ts new file mode 100644 index 000000000000..f97c7c7fc1e1 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/disable-upload.test.ts @@ -0,0 +1,123 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { webpack } from 'webpack'; +import type { Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; + +function build(config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +describe('sourcemaps.disable: "disable-upload"', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-disable-upload-')); + fs.writeFileSync(path.join(tmpDir, 'entry.js'), 'export const answer = 42;\nconsole.log(answer);\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stamps the emitted source map with the debug ID injected into the bundle', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + devtool: 'source-map', + output: { path: outDir, filename: 'bundle.js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'bundle.js.map'), 'utf8')); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + expect(bundle).toContain(`//# debugId=${debugId}`); + }); + + it('stamps a hidden source map and keeps content-hashed file names consistent', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + devtool: 'hidden-source-map', + output: { path: outDir, filename: '[name].[contenthash].js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + const bundleFileName = fs.readdirSync(outDir).find(file => file.endsWith('.js')); + expect(bundleFileName).toBeDefined(); + + const bundle = fs.readFileSync(path.join(outDir, bundleFileName!), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, `${bundleFileName}.map`), 'utf8')); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(bundle).not.toContain('sourceMappingURL'); + expect(map.debug_id).toBe(debugId); + expect(bundle).toContain(`//# debugId=${debugId}`); + // The real content hash is recomputed after stamping, so the map's `file` must match the final name. + expect(map.file).toBe(bundleFileName); + }); + + it('does not stamp emitted source maps when uploading is enabled', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + devtool: 'source-map', + output: { path: outDir, filename: 'bundle.js' }, + // No auth token, so the upload itself is skipped with a warning. + plugins: [sentryWebpackPlugin({ telemetry: false })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'bundle.js.map'), 'utf8')); + + expect(map).not.toHaveProperty('debug_id'); + expect(bundle).not.toContain('//# debugId='); + }); + + it('stamps the bundle when the source map is inlined into the bundle', async () => { + const outDir = path.join(tmpDir, 'dist'); + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + devtool: 'inline-source-map', + output: { path: outDir, filename: 'bundle.js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, sourcemaps: { disable: 'disable-upload' } })], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8'); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(bundle).toContain(`//# debugId=${debugId}`); + }); +});