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
8 changes: 8 additions & 0 deletions .changeset/sentry-debug-id-plugin.md
Original file line number Diff line number Diff line change
@@ -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`.
130 changes: 130 additions & 0 deletions packages/repack/src/plugins/SentryDebugIdPlugin.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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<string, unknown>;
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))
);
}
}
}
);
}
);
}
}
74 changes: 74 additions & 0 deletions packages/repack/src/plugins/__tests__/SentryDebugIdPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
1 change: 1 addition & 0 deletions packages/repack/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 5 additions & 0 deletions website/src/latest/api/plugins/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"name": "hermes-bytecode",
"label": "HermesBytecodePlugin"
},
{
"type": "file",
"name": "sentry-debug-id",
"label": "SentryDebugIdPlugin"
},
{
"type": "file",
"name": "internal",
Expand Down
39 changes: 39 additions & 0 deletions website/src/latest/api/plugins/sentry-debug-id.md
Original file line number Diff line number Diff line change
@@ -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.