From ffff9109ac16c69f18ad177597bcf9006b632067 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Fri, 4 Sep 2026 10:11:51 +0200 Subject: [PATCH] feat: add SentryDebugIdPlugin Stamps a Sentry Debug ID into the bundle and its source map, so Sentry can pair a JS stack trace with the source map that resolves it. This is the Re.Pack counterpart of what @sentry/react-native provides for Metro through createSentryMetroSerializer. --- .changeset/sentry-debug-id-plugin.md | 8 ++ .../repack/src/plugins/SentryDebugIdPlugin.ts | 130 ++++++++++++++++++ .../__tests__/SentryDebugIdPlugin.test.ts | 74 ++++++++++ packages/repack/src/plugins/index.ts | 1 + website/src/latest/api/plugins/_meta.json | 5 + .../src/latest/api/plugins/sentry-debug-id.md | 39 ++++++ 6 files changed, 257 insertions(+) create mode 100644 .changeset/sentry-debug-id-plugin.md create mode 100644 packages/repack/src/plugins/SentryDebugIdPlugin.ts create mode 100644 packages/repack/src/plugins/__tests__/SentryDebugIdPlugin.test.ts create mode 100644 website/src/latest/api/plugins/sentry-debug-id.md diff --git a/.changeset/sentry-debug-id-plugin.md b/.changeset/sentry-debug-id-plugin.md new file mode 100644 index 000000000..2587b071f --- /dev/null +++ b/.changeset/sentry-debug-id-plugin.md @@ -0,0 +1,8 @@ +--- +"@callstack/repack": minor +--- + +Add `SentryDebugIdPlugin`, which stamps a Sentry Debug ID into the bundle and +its source map so Sentry can pair a JS stack trace with the source map that +resolves it. This is the Re.Pack counterpart of what `@sentry/react-native` +provides for Metro through `createSentryMetroSerializer`. diff --git a/packages/repack/src/plugins/SentryDebugIdPlugin.ts b/packages/repack/src/plugins/SentryDebugIdPlugin.ts new file mode 100644 index 000000000..8d9848fd7 --- /dev/null +++ b/packages/repack/src/plugins/SentryDebugIdPlugin.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto'; +import type { Compiler as RspackCompiler } from '@rspack/core'; +import type { Compiler as WebpackCompiler } from 'webpack'; + +/** + * {@link SentryDebugIdPlugin} configuration options. + */ +export interface SentryDebugIdPluginConfig { + /** + * Matches the bundles that should receive a Debug ID. + * + * @default /\.([cm]?jsx?|bundle)$/ + */ + test?: RegExp; +} + +const DEFAULT_TEST = /\.([cm]?jsx?|bundle)$/; + +/** + * Creates the snippet that registers the Debug ID on the global object. + * + * Mirrors what `@sentry/bundler-plugin-core` injects, so the Sentry SDK picks the id up at + * runtime without any additional configuration. + */ +function getDebugIdSnippet(debugId: string) { + return `;{try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}\n`; +} + +/** + * Plugin for stamping a Sentry Debug ID into the bundle and its source map. + * + * A Debug ID is a UUID present in both artifacts. Sentry uses it to pair a JS stack trace with + * the source map that resolves it, which is what Metro provides through + * `@sentry/react-native`'s `createSentryMetroSerializer`. + * + * `@sentry/webpack-plugin` cannot fill this role for React Native: it injects through a + * `BannerPlugin` whose `include` only matches `.js`-like filenames, so it skips `index.bundle`, + * and it writes the Debug ID only into the source map copy it uploads, never into the emitted + * asset that `@sentry/react-native`'s Gradle and Xcode scripts read. + * + * @example Usage in Rspack config (ESM): + * ```ts + * import * as Repack from '@callstack/repack'; + * + * export default (env) => ({ + * plugins: [new Repack.SentryDebugIdPlugin()], + * }); + * ``` + * + * @category Webpack Plugin + */ +export class SentryDebugIdPlugin { + constructor(private config: SentryDebugIdPluginConfig = {}) {} + + apply(compiler: RspackCompiler): void; + apply(compiler: WebpackCompiler): void; + + apply(__compiler: unknown) { + const compiler = __compiler as RspackCompiler; + const logger = compiler.getInfrastructureLogger( + 'RepackSentryDebugIdPlugin' + ); + const { BannerPlugin, Compilation, sources } = compiler.webpack; + const test = this.config.test ?? DEFAULT_TEST; + const debugIdsByChunk = new Map(); + + const getDebugId = (name: string) => { + let debugId = debugIdsByChunk.get(name); + if (!debugId) { + debugId = randomUUID(); + debugIdsByChunk.set(name, debugId); + } + return debugId; + }; + + new BannerPlugin({ + raw: true, + include: test, + banner: ({ chunk }) => { + const name = chunk?.name ?? chunk?.id?.toString() ?? 'main'; + return getDebugIdSnippet(getDebugId(name)); + }, + }).apply(compiler); + + compiler.hooks.thisCompilation.tap( + 'RepackSentryDebugIdPlugin', + (compilation) => { + // Source maps are emitted during the devtool stage, so stamp them once that has run. + compilation.hooks.processAssets.tap( + { + name: 'RepackSentryDebugIdPlugin', + stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + (assets) => { + for (const chunk of compilation.chunks) { + const name = chunk.name ?? chunk.id?.toString() ?? 'main'; + const debugId = debugIdsByChunk.get(name); + if (!debugId) { + continue; + } + + for (const file of chunk.auxiliaryFiles) { + if (!file.endsWith('.map') || !assets[file]) { + continue; + } + + let sourceMap: Record; + try { + sourceMap = JSON.parse(assets[file].source().toString()); + } catch { + logger.warn(`Could not parse ${file}, skipping Debug ID.`); + continue; + } + + // Sentry's tooling reads either spelling depending on version, so write both. + sourceMap.debugId = debugId; + sourceMap.debug_id = debugId; + + compilation.updateAsset( + file, + new sources.RawSource(JSON.stringify(sourceMap)) + ); + } + } + } + ); + } + ); + } +} diff --git a/packages/repack/src/plugins/__tests__/SentryDebugIdPlugin.test.ts b/packages/repack/src/plugins/__tests__/SentryDebugIdPlugin.test.ts new file mode 100644 index 000000000..6567d1794 --- /dev/null +++ b/packages/repack/src/plugins/__tests__/SentryDebugIdPlugin.test.ts @@ -0,0 +1,74 @@ +import { rspack } from '@rspack/core'; +import memfs from 'memfs'; +import RspackVirtualModulePlugin from 'rspack-plugin-virtual-module'; + +import { SentryDebugIdPlugin } from '../SentryDebugIdPlugin.js'; + +const DEBUG_ID_REGEX = + /_sentryDebugIdIdentifier\s*=\s*"sentry-dbid-([0-9a-f-]{36})"/; + +async function compileBundle(outputFilename: string) { + const fileSystem = memfs.createFsFromVolume(new memfs.Volume()); + + const compiler = rspack({ + context: __dirname, + mode: 'production', + devtool: 'source-map', + entry: 'index.js', + output: { + filename: outputFilename, + path: '/out', + }, + plugins: [ + new SentryDebugIdPlugin(), + new RspackVirtualModulePlugin({ + 'index.js': "console.log('hello');", + }), + ], + }); + + // @ts-expect-error memfs is compatible enough for the compiler's output filesystem + compiler.outputFileSystem = fileSystem; + + return new Promise<{ bundle: string; sourceMap: string }>( + (resolve, reject) => { + compiler.run((error) => { + if (error) { + reject(error); + return; + } + compiler.close(() => { + resolve({ + bundle: fileSystem + .readFileSync(`/out/${outputFilename}`, 'utf-8') + .toString(), + sourceMap: fileSystem + .readFileSync(`/out/${outputFilename}.map`, 'utf-8') + .toString(), + }); + }); + }); + } + ); +} + +describe('SentryDebugIdPlugin', () => { + it('injects a Debug ID into the bundle and its source map', async () => { + const { bundle, sourceMap } = await compileBundle('index.bundle'); + + const injected = bundle.match(DEBUG_ID_REGEX); + expect(injected).not.toBeNull(); + + const parsedSourceMap = JSON.parse(sourceMap); + expect(parsedSourceMap.debugId).toBe(injected?.[1]); + expect(parsedSourceMap.debug_id).toBe(injected?.[1]); + }); + + it('injects a Debug ID for bundles with a .js extension', async () => { + const { bundle, sourceMap } = await compileBundle('index.js'); + + const injected = bundle.match(DEBUG_ID_REGEX); + expect(injected).not.toBeNull(); + expect(JSON.parse(sourceMap).debugId).toBe(injected?.[1]); + }); +}); diff --git a/packages/repack/src/plugins/index.ts b/packages/repack/src/plugins/index.ts index a4c866e44..50b09cdd5 100644 --- a/packages/repack/src/plugins/index.ts +++ b/packages/repack/src/plugins/index.ts @@ -10,3 +10,4 @@ export * from './ModuleFederationPluginV2.js'; export * from './NativeEntryPlugin/index.js'; export * from './OutputPlugin/index.js'; export * from './RepackTargetPlugin/index.js'; +export * from './SentryDebugIdPlugin.js'; diff --git a/website/src/latest/api/plugins/_meta.json b/website/src/latest/api/plugins/_meta.json index 676edb6af..b98d0ebe8 100644 --- a/website/src/latest/api/plugins/_meta.json +++ b/website/src/latest/api/plugins/_meta.json @@ -24,6 +24,11 @@ "name": "hermes-bytecode", "label": "HermesBytecodePlugin" }, + { + "type": "file", + "name": "sentry-debug-id", + "label": "SentryDebugIdPlugin" + }, { "type": "file", "name": "internal", diff --git a/website/src/latest/api/plugins/sentry-debug-id.md b/website/src/latest/api/plugins/sentry-debug-id.md new file mode 100644 index 000000000..d0a66e500 --- /dev/null +++ b/website/src/latest/api/plugins/sentry-debug-id.md @@ -0,0 +1,39 @@ +# SentryDebugId Plugin + +This plugin stamps a [Sentry](https://sentry.io) Debug ID into the bundle and its source map. + +A Debug ID is a UUID present in both artifacts. The Sentry SDK reports it with every event, and Sentry uses it to pair a JS stack trace with the source map that resolves it. Without it, symbolication falls back to matching on `release` and `dist`, which only works when the values used at upload time match the ones the SDK reports at runtime. + +For Metro, `@sentry/react-native` provides this through `createSentryMetroSerializer`. This plugin is the Re.Pack counterpart. + +:::info Why `@sentry/webpack-plugin` is not enough +`@sentry/webpack-plugin` injects Debug IDs through a `BannerPlugin` whose `include` only matches `.js`-like filenames, so it skips React Native bundle names such as `index.bundle`. It also writes the Debug ID only into the source map copy it uploads, never into the emitted asset, which is what `@sentry/react-native`'s Gradle and Xcode upload scripts read. + +You can use both plugins together: this one handles the Debug ID, `@sentry/webpack-plugin` handles release creation and upload. +::: + +## Usage + +```js title="rspack.config.cjs" +const Repack = require("@callstack/repack"); + +module.exports = { + plugins: [ + new Repack.SentryDebugIdPlugin({ + // options + }), + ], +}; +``` + +Debug IDs are only useful for release builds, so you will usually add the plugin for production compilations only. + +## Options + +### test + +- Type: `RegExp` +- Required: `false` +- Default: `/\.([cm]?jsx?|bundle)$/` + +Matches the bundles that should receive a Debug ID.