feat: add SentryDebugIdPlugin by elirangoshen · Pull Request #1451 · callstack/repack · GitHub
Skip to content

feat: add SentryDebugIdPlugin - #1451

Open
elirangoshen wants to merge 2 commits into
callstack:mainfrom
elirangoshen:feat/sentry-debug-id-plugin
Open

elirangoshen wants to merge 2 commits into
callstack:mainfrom
elirangoshen:feat/sentry-debug-id-plugin

Conversation

@elirangoshen

Copy link
Copy Markdown
Contributor

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-native provides this through createSentryMetroSerializer; Re.Pack currently has no equivalent, so apps that migrate lose JS symbolication.

Why @sentry/webpack-plugin doesn't cover this

Two independent gaps, both found while migrating a large app (Expensify, ~35MB minified prod bundle) from Metro to Re.Pack:

  1. The injection never applies. The plugin stamps the bundle through a BannerPlugin with a hardcoded include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/. Re.Pack emits index.bundle, so nothing matches and no snippet is injected — silently, no warning.
  2. Emitted source maps never get the ID. prepareSourceMapForDebugIdUpload() sets debugId/debug_id on a copy written to the upload directory, never on the emitted asset. @sentry/react-native uploads through its own Gradle/Xcode integration, which reads the on-disk map: scripts/copy-debugid.js copies the ID onto the Hermes composed map and scripts/has-sourcemap-debugid.js verifies 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-plugin still handles release creation and upload.

Implementation

  • Injects the snippet via BannerPlugin, defaulting test to /\.([cm]?jsx?|bundle)$/ — the same pattern SourceMapPlugin already uses, so .bundle and .js outputs both work.
  • Writes debugId and debug_id into emitted .map assets in processAssets at PROCESS_ASSETS_STAGE_REPORT, after the devtool stage. Sentry's tooling reads either spelling depending on version, so both are written.
  • Not wired into 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 memfs and assert the bundle stamp matches the map's debugId, for both index.bundle and index.js outputs.

Also verified end to end in the app on iOS and Android production bundles:

Check Before After
map debugId absent present (debugId + debug_id)
bundle sentry-dbid-<uuid> stamp absent present, matches the map
has-sourcemap-debugid.js fails prints UUID, exit 0
copy-debugid.js → Hermes map no-op copies the ID

pnpm jest src/plugins/__tests__ → 82 tests pass (7 suites), pnpm --filter @callstack/repack typecheck clean, pnpm biome check clean. Changeset and a docs page under website/src/latest/api/plugins/ included.

Env: @rspack/core 2.1.3, react-native 0.86.0, @sentry/react-native 8.7.0, @sentry/webpack-plugin 5.2.0.

Open questions for maintainers

  • Would you rather this lived in a separate packages/plugin-sentry package, alongside plugin-reanimated / plugin-expo-modules? Happy to move it.
  • I kept it out of RepackPlugin. If you'd prefer an opt-in flag there (e.g. sentry: true), that's an easy follow-up.

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-bot

changeset-bot Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8e13f84

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Minor
@callstack/repack-plugin-expo-modules Minor
@callstack/repack-plugin-nativewind Minor
@callstack/repack-plugin-reanimated Minor
@callstack/repack-dev-server Minor
@callstack/repack-init Minor

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

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

@elirangoshen is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@elirangoshen
elirangoshen marked this pull request as ready for review September 4, 2026 08:22

@dannyhw dannyhw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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-plugin 5.4.1 (@sentry/bundler-plugins 11.x) no longer uses BannerPlugin. It injects at processAssets ADDITIONS, filtered by WEBPACK_JAVASCRIPT_ASSET_REGEX. It now also stamps emitted maps in sourcemaps.disable: 'disable-upload' mode, but only for files passing isJsFile() (.js/.mjs/.cjs). .bundle is 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. composeSourceMaps runs RN's compose-source-maps.js and replaces the packager map. As far as I can tell, the composed map has no debugId, so remote chunks compiled to bytecode by Re.Pack would lose it. (Not verified here; the main bundle is covered by RN's native copy-debugid.js step.) Please check this and either copy the ID across or document the limitation.
  • Placement. I'd suggest a separate packages/plugin-sentry rather than core. It's vendor-specific, the snippet has to follow Sentry's format, and it matches the plugin-reanimated / plugin-expo-modules pattern. Keeping it out of RepackPlugin is right.

const getDebugId = (name: string) => {
let debugId = debugIdsByChunk.get(name);
if (!debugId) {
debugId = randomUUID();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+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>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 SourceMapPlugin present and the map came out stamped and correctly paired — but nothing guards that today, and the dual-compiler support is the whole reason apply is 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.

@CristiCeban

Copy link
Copy Markdown
Contributor

The Hermes interaction is real — I was able to verify it locally, and the scope is slightly different from what the review above assumes.

copy-debugid.js does cover index.bundle, but only because RN's own pipeline leaves two separate files on disk: the packager map and the composed Hermes map. It can't help remote chunks, because HermesBytecodePlugin ends up with exactly one map — composeSourceMaps.ts:42-45 unlinks the stamped packager map and renames the composed one over it, so there is no second file for copy-debugid to read from.

Reproduced on a real release bundle (RN 0.88, 3.1MB map), driving transformBundleToHermesBytecode's exact flags and then composeSourceMaps:

BEFORE: debugId 74f197a7-…   keys: version,file,sources,sourcesContent,names,mappings,debugId,debug_id
AFTER : debugId DROPPED      keys: version,sources,names,mappings,sourcesContent,x_facebook_sources,x_hermes_function_offsets

has-sourcemap-debugid.js: exit 1 -> does not contain 'debugId' nor 'debug_id'.
copy-debugid.js:          exit 0 -> Packager source map does not have `debugId`.

The cause is compose-source-maps.js building a fresh SourceMapGenerator, so its output is only {version,file,sources,names,mappings,sourcesContent,x_facebook_*}. That copy-debugid.js line is also character-for-character what getsentry/sentry-react-native#3956 reports.

The fix looks small: read debugId off the input map in composeSourceMaps.ts and re-apply it to the composed output before the rename. I'd lean toward doing that rather than documenting the limitation, since remote chunks are exactly the Module Federation setup this plugin will attract — and it also means the feature can't be complete from a separate package.

@CristiCeban

Copy link
Copy Markdown
Contributor

Verified the build claims locally on a real RN 0.88 release bundle (~810KB bundle, 3.1MB map):

plugin off plugin on
sentry-dbid-<uuid> in bundle absent present
map debugId / debug_id absent present
has-sourcemap-debugid.js exit 1 exit 0
bundle ↔ map pair — match

The mapping-integrity claim holds too, which is the part I was most sceptical about: the same generated token resolves to the same original line:column with and without the plugin (bundle 1180→1557 bytes, original position identical). No column drift.

Repo gates are green on the branch: pnpm test 13/13 tasks exit 0, pnpm lint exit 0 with biome not touching any file in the diff, tsc --noEmit 0 errors — identical to a clean origin/main baseline.
Small description nit: it lists @rspack/core 2.1.3, but the branch resolves 1.6.0. Worth correcting so anyone reproducing the verification ends up on the same code path.

* import * as Repack from '@callstack/repack';
*
* export default (env) => ({
* plugins: [new Repack.SentryDebugIdPlugin()],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 SourceMapPlugin present and the map came out stamped and correctly paired — but nothing guards that today, and the dual-compiler support is the whole reason apply is 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`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@elirangoshen

Copy link
Copy Markdown
Contributor Author

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants