Repository navigation
feat: add SentryDebugIdPlugin - #1451
elirangoshen wants to merge 2 commits into
Conversation
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 detectedLatest commit: 8e13f84 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@elirangoshen is attempting to deploy a commit to the Callstack Team on Vercel. A member of the Team first needs to authorize it. |
dannyhw
left a comment
There was a problem hiding this comment.
automated review:
The gap is real (see getsentry/sentry-react-native#3956), and per-chunk IDs are an improvement over the community gronxb/repack-plugin-sentry. Inline comments cover the code. Some broader points:
- PR description is out of date.
@sentry/webpack-plugin5.4.1 (@sentry/bundler-plugins11.x) no longer usesBannerPlugin. It injects atprocessAssetsADDITIONS, filtered byWEBPACK_JAVASCRIPT_ASSET_REGEX. It now also stamps emitted maps insourcemaps.disable: 'disable-upload'mode, but only for files passingisJsFile()(.js/.mjs/.cjs)..bundleis still excluded, so the gap stands. Please update the description. It may also be worth raising upstream, since relaxing those two filters would cover most of this. - HermesBytecodePlugin likely drops the ID.
composeSourceMapsruns RN'scompose-source-maps.jsand replaces the packager map. As far as I can tell, the composed map has nodebugId, so remote chunks compiled to bytecode by Re.Pack would lose it. (Not verified here; the main bundle is covered by RN's nativecopy-debugid.jsstep.) Please check this and either copy the ID across or document the limitation. - Placement. I'd suggest a separate
packages/plugin-sentryrather than core. It's vendor-specific, the snippet has to follow Sentry's format, and it matches theplugin-reanimated/plugin-expo-modulespattern. Keeping it out ofRepackPluginis right.
| const getDebugId = (name: string) => { | ||
| let debugId = debugIdsByChunk.get(name); | ||
| if (!debugId) { | ||
| debugId = randomUUID(); |
There was a problem hiding this comment.
automated review: Make Debug IDs deterministic. Both Sentry tools derive the ID from content: Metro uses md5 of the bundle, and the webpack plugin uses stringToUUID(chunk.contentHash.javascript ?? chunk.hash). Random UUIDs make builds non-reproducible and give identical bundles different IDs.
There was a problem hiding this comment.
+1 on deriving from content. One trap worth flagging for whoever picks this up: the two comments here are really one bug (content-derived IDs delete both the map and the staleness class), but a content hash taken after injection is circular — the banner contains the ID, so the hash depends on its own output.
Upstream avoids that by deriving from the pre-injection hash, which is also why they inject at processAssets ADDITIONS instead of through BannerPlugin. So this is the right direction, but it's closer to moving injection into processAssets than to swapping randomUUID() for a hash.
| ); | ||
| const { BannerPlugin, Compilation, sources } = compiler.webpack; | ||
| const test = this.config.test ?? DEFAULT_TEST; | ||
| const debugIdsByChunk = new Map<string, string>(); |
There was a problem hiding this comment.
automated review: debugIdsByChunk lives for the whole compiler and is never reset between compilations. In watch mode, a rebuilt chunk keeps its old ID even when its content changes. A content-derived ID (see the other comment) removes the need for this map.
| const name = chunk?.name ?? chunk?.id?.toString() ?? 'main'; | ||
| return getDebugIdSnippet(getDebugId(name)); | ||
| }, | ||
| }).apply(compiler); |
There was a problem hiding this comment.
automated review: Also emit the //# debugId=<id> comment, before //# sourceMappingURL if present. Metro's serializer, @sentry/webpack-plugin and the community Re.Pack plugin all do this. sentry-cli uses it to pair bundle and map, which matters for JSC builds and for uploads outside the Gradle/Xcode scripts.
| apply(compiler: RspackCompiler): void; | ||
| apply(compiler: WebpackCompiler): void; | ||
|
|
||
| apply(__compiler: unknown) { |
There was a problem hiding this comment.
automated review: Skip in development (non-production mode or dev server) instead of leaving it to the docs. createSentryMetroSerializer returns early for dev/hot builds.
| compilation.hooks.processAssets.tap( | ||
| { | ||
| name: 'RepackSentryDebugIdPlugin', | ||
| stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, |
There was a problem hiding this comment.
automated review: PROCESS_ASSETS_STAGE_REPORT runs after real content hashing, so the map is changed after its hash is computed. Sentry uses PROCESS_ASSETS_STAGE_DEV_TOOLING + 1, right after source map generation, so later stages see the final assets. Consider doing the same.
| ); | ||
| } | ||
|
|
||
| describe('SentryDebugIdPlugin', () => { |
There was a problem hiding this comment.
automated review: Coverage is single-entry only. Please add:
- multiple chunks: each gets a distinct ID that matches its own map
- the dev-mode skip
- the
//# debugId=comment, if added - a deterministic ID when building the same input twice
There was a problem hiding this comment.
Two more worth adding to this list:
- a webpack-as-compiler case, since CI only exercises rspack. The plugin does work under real webpack 5.105.4 — I ran it with
SourceMapPluginpresent and the map came out stamped and correctly paired — but nothing guards that today, and the dual-compiler support is the whole reasonapplyis typed the way it is. - one that goes through the package entry (
Repack.plugins.SentryDebugIdPlugin) rather than importing the class directly, so the documented import path is actually covered.
|
The Hermes interaction is real — I was able to verify it locally, and the scope is slightly different from what the review above assumes.
Reproduced on a real release bundle (RN 0.88, 3.1MB map), driving The cause is The fix looks small: read |
|
Verified the build claims locally on a real RN 0.88 release bundle (~810KB bundle, 3.1MB map):
The mapping-integrity claim holds too, which is the part I was most sceptical about: the same generated token resolves to the same original Repo gates are green on the branch: |
| * import * as Repack from '@callstack/repack'; | ||
| * | ||
| * export default (env) => ({ | ||
| * plugins: [new Repack.SentryDebugIdPlugin()], |
There was a problem hiding this comment.
The JSDoc example and the docs page (sentry-debug-id.md) both show new Repack.SentryDebugIdPlugin(), but that isn't a valid import — src/index.ts:2 is export * as plugins from './plugins/index.js', so only RepackPlugin is re-exported at the top level.
I ran it against a real project and it fails at config load:
error Repack.SentryDebugIdPlugin is not a constructor.
at file:///…/rspack.config.mjs:61:22
at async loadProjectConfig (…/dist/commands/common/config/loadProjectConfig.js:42:14)
new Repack.plugins.SentryDebugIdPlugin() works and builds. That also matches the convention the other plugin docs already use — hermes-bytecode.md:28 and code-signing.md:12 both say Repack.plugins.*.
Please fix the example here and the usage snippet in the docs page. It would also be worth one test that imports through the package entry rather than constructing the class directly — right now the public surface is completely untested, which is why this slipped through.
|
|
||
| return new Promise<{ bundle: string; sourceMap: string }>( | ||
| (resolve, reject) => { | ||
| compiler.run((error) => { |
There was a problem hiding this comment.
stats.hasErrors() is never checked here, so if the compilation itself fails the test doesn't report the build error — it fails later on readFileSync with a confusing ENOENT for a file that was never written. Worth checking stats.hasErrors() and rejecting with the real message first.
| const getDebugId = (name: string) => { | ||
| let debugId = debugIdsByChunk.get(name); | ||
| if (!debugId) { | ||
| debugId = randomUUID(); |
There was a problem hiding this comment.
+1 on deriving from content. One trap worth flagging for whoever picks this up: the two comments here are really one bug (content-derived IDs delete both the map and the staleness class), but a content hash taken after injection is circular — the banner contains the ID, so the hash depends on its own output.
Upstream avoids that by deriving from the pre-injection hash, which is also why they inject at processAssets ADDITIONS instead of through BannerPlugin. So this is the right direction, but it's closer to moving injection into processAssets than to swapping randomUUID() for a hash.
| ); | ||
| } | ||
|
|
||
| describe('SentryDebugIdPlugin', () => { |
There was a problem hiding this comment.
Two more worth adding to this list:
- a webpack-as-compiler case, since CI only exercises rspack. The plugin does work under real webpack 5.105.4 — I ran it with
SourceMapPluginpresent and the map came out stamped and correctly paired — but nothing guards that today, and the dual-compiler support is the whole reasonapplyis typed the way it is. - one that goes through the package entry (
Repack.plugins.SentryDebugIdPlugin) rather than importing the class directly, so the documented import path is actually covered.
| * 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`; |
There was a problem hiding this comment.
The body of this snippet is verbatim from @sentry/bundler-plugin-core (var n=(new e.Error).stack;n&&(e._sentryDebugIds=…) — only the wrapper differs (;{try{…}} here vs their CodeInjection header/footer, !function(){try{…}}();).
The comment says "mirrors", which reads as reimplemented-from-scratch. A short source note here and in the changeset would be the honest thing, and it also gives future maintainers a pointer to diff against when Sentry changes the format.
|
|
||
| let sourceMap: Record<string, unknown>; | ||
| try { | ||
| sourceMap = JSON.parse(assets[file].source().toString()); |
There was a problem hiding this comment.
Not blocking, just recording the cost, since source maps are the biggest asset in the build. Measured on a real release map:
| map size | time | RSS delta |
|---|---|---|
| 3.1MB | 6ms | +14MB |
| 28.9MB | 38ms | +202MB |
Time is irrelevant; peak memory is the thing to keep an eye on — ~200MB transient per chunk, on top of a production build already close to node's heap limit. Fine to leave as is. If it ever matters, splicing "debugId":"…" into the JSON text avoids the parse/stringify round-trip entirely.

Summary
Adds
SentryDebugIdPlugin, which stamps a Sentry 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. For Metro,
@sentry/react-nativeprovides this throughcreateSentryMetroSerializer; Re.Pack currently has no equivalent, so apps that migrate lose JS symbolication.Why
@sentry/webpack-plugindoesn't cover thisTwo independent gaps, both found while migrating a large app (Expensify, ~35MB minified prod bundle) from Metro to Re.Pack:
BannerPluginwith a hardcodedinclude: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/. Re.Pack emitsindex.bundle, so nothing matches and no snippet is injected — silently, no warning.prepareSourceMapForDebugIdUpload()setsdebugId/debug_idon a copy written to the upload directory, never on the emitted asset.@sentry/react-nativeuploads through its own Gradle/Xcode integration, which reads the on-disk map:scripts/copy-debugid.jscopies the ID onto the Hermes composed map andscripts/has-sourcemap-debugid.jsverifies it. Both no-op when the field is absent, so the build succeeds and symbolication quietly fails.The two plugins compose fine — this one handles the Debug ID,
@sentry/webpack-pluginstill handles release creation and upload.Implementation
BannerPlugin, defaultingtestto/\.([cm]?jsx?|bundle)$/— the same patternSourceMapPluginalready uses, so.bundleand.jsoutputs both work.debugIdanddebug_idinto emitted.mapassets inprocessAssetsatPROCESS_ASSETS_STAGE_REPORT, after the devtool stage. Sentry's tooling reads either spelling depending on version, so both are written.RepackPlugin— Sentry is opt-in, so users add the plugin explicitly.Because injection happens through
BannerPlugin, source maps are generated after the banner and account for the added bytes; column offsets are not shifted. Verified against a no-injection build of the same app: the first mapping segment changes (60BAwJA→mqCAwJA) rather than staying identical.Verification
Unit tests do a real Rspack compile through
memfsand assert the bundle stamp matches the map'sdebugId, for bothindex.bundleandindex.jsoutputs.Also verified end to end in the app on iOS and Android production bundles:
debugIddebugId+debug_id)sentry-dbid-<uuid>stamphas-sourcemap-debugid.jscopy-debugid.js→ Hermes mappnpm jest src/plugins/__tests__→ 82 tests pass (7 suites),pnpm --filter @callstack/repack typecheckclean,pnpm biome checkclean. Changeset and a docs page underwebsite/src/latest/api/plugins/included.Env:
@rspack/core2.1.3,react-native0.86.0,@sentry/react-native8.7.0,@sentry/webpack-plugin5.2.0.Open questions for maintainers
packages/plugin-sentrypackage, alongsideplugin-reanimated/plugin-expo-modules? Happy to move it.RepackPlugin. If you'd prefer an opt-in flag there (e.g.sentry: true), that's an easy follow-up.