Uploading Source Maps for Readable Stack Traces
Turn minified extension stack traces into readable ones in your error service: generating hidden source maps, normalising chrome-extension:// frame URLs, uploading maps per release from CI, matching releases, and keeping maps out of the package.
Table of Contents
The error dashboard shows hundreds of reports of TypeError: Cannot read properties of null (reading 'parentNode') at o (chrome-extension://fjkmabmdepjfammlpliljpnbhleegehm/content.js:2:1847). Without source maps, that frame is useless; with source maps uploaded to the error service, it reads at removeHighlight (src/content/highlights.ts:142:18) with the surrounding code. Two extension-specific problems usually get in the way: the frame URLs contain the extension ID, which differs between store, unpacked and other-browser builds, so maps never match; and the maps must not be inside the package users download. This guide sets up the pipeline that solves both. It belongs to error monitoring and crash reporting.
How symbolication matches maps to frames
An error service symbolicates a stack frame by finding a source map whose associated file URL matches the frame’s URL, for the same release. In a web app, the URL is stable (https://app.example/assets/main-3f2a.js). In an extension, it is chrome-extension://<id>/content.js in Chrome, moz-extension://<random-uuid>/content.js in Firefox — where the UUID is random per installation — and safari-web-extension://<uuid>/ in Safari. If you upload maps under one of those URLs, only that one installation matches. The fix is to rewrite frame URLs before sending to a fixed, extension-independent prefix such as app:///content.js, and upload maps under the same prefix. Release identifiers must also match exactly between the error event and the uploaded maps.
Step-by-step: readable traces in production
1. Generate hidden source maps
1// vite.config.ts
2export default defineConfig({
3 build: { sourcemap: "hidden", minify: "esbuild" },
4});
Execution context: the build. Hidden maps are written as .map files without a sourceMappingURL comment, so browsers never request them and the package can omit them. Keep sourcesContent so the error service can show code context. See debugging production builds with source maps.
2. Normalise frame URLs before sending
1// error-reporting.js
2const ORIGIN = chrome.runtime.getURL(""); // "chrome-extension://<id>/" or "moz-extension://<uuid>/"
3
4export function normaliseFrames(event) {
5 for (const ex of event.exception?.values ?? []) {
6 for (const f of ex.stacktrace?.frames ?? []) {
7 if (f.filename?.startsWith(ORIGIN)) f.filename = "app:///" + f.filename.slice(ORIGIN.length);
8 }
9 }
10 return event;
11}
12
13// Sentry example
14Sentry.init({ dsn: DSN, release: `readable@${chrome.runtime.getManifest().version}`, beforeSend: normaliseFrames });
Execution context: every context that reports errors. runtime.getURL("") gives the current installation’s origin in any browser, so one function handles Chrome, Firefox and Safari. The release name is derived from the manifest version so it always matches what CI uploads. Many SDKs also provide a “rewrite frames” integration that does this. See wiring Sentry into a Manifest V3 extension.
3. Upload maps from CI for each release
1VERSION=$(node -p "require('./dist/chrome/manifest.json').version")
2npx @sentry/cli releases new "readable@$VERSION"
3npx @sentry/cli sourcemaps upload --release "readable@$VERSION" --url-prefix "app:///" --validate dist/chrome
4npx @sentry/cli releases finalize "readable@$VERSION"
Execution context: the release job, after building and before packaging. The --url-prefix app:/// matches the normalised frames; --validate checks that each map references its file correctly. Run this with an auth token from CI secrets. Other services (Bugsnag, Rollbar, self-hosted) have equivalent upload commands with a “minified URL” parameter — use the same prefix. See managing store credentials in CI secrets.
4. Remove maps before packaging
1find dist -name '*.map' -delete
2node scripts/zip.mjs
3unzip -l release/extension-$VERSION.zip | grep -q '\.map$' && { echo "map in package"; exit 1; } || true
Execution context: the release job. Delete maps after uploading them, then assert the archive contains none — a guard against a future build change re-adding them. See building reproducible release ZIPs.
5. Upload per browser build when outputs differ
If Chrome and Firefox builds produce different bundles (different polyfills, different entry points), upload each build’s maps under a distinct release or distribution — for example readable@2.4.0 with dist=chrome and dist=firefox — and set the same dist in each build’s SDK configuration. Mismatched maps produce confidently wrong line numbers, which are worse than none.
6. Verify with a test error
1// In the service worker console of the released build
2setTimeout(() => { throw new Error("source map check " + chrome.runtime.getManifest().version); });
Execution context: a store-installed or packaged build. Throw a deliberate error after each release and confirm in the error service that it shows the original file and line. Make this a release checklist step; source map configuration breaks silently.
7. Keep context lines private
Source map uploads include your source code (via sourcesContent) on the error service. That is usually acceptable for a service you trust with your error data; if not, strip sourcesContent before upload — you still get file and line, just without inline code.
8. Automate the upload in the release workflow
1# .github/workflows/release.yml (excerpt)
2 - run: npm ci --ignore-scripts && npm run build
3 - name: Upload source maps
4 run: |
5 VERSION=$(node -p "require('./dist/chrome/manifest.json').version")
6 npx @sentry/cli sourcemaps upload --release "readable@$VERSION" --dist chrome --url-prefix "app:///" --validate dist/chrome
7 npx @sentry/cli sourcemaps upload --release "readable@$VERSION" --dist firefox --url-prefix "app:///" --validate dist/firefox
8 env:
9 SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
10 SENTRY_ORG: readable
11 SENTRY_PROJECT: extension
12 - run: find dist -name '*.map' -delete
13 - run: npm run package
Execution context: GitHub Actions on release tags. Putting the upload in the same job that builds the package guarantees the maps come from exactly the bytes that ship; a separate job that rebuilds risks subtle differences. The auth token is scoped to uploading releases only and stored as a CI secret. If the upload step fails, the job fails before packaging, so a release never ships without its maps.
Common mistakes
- Uploading maps under
chrome-extension://<id>. Only one installation matches. - Release names that differ from the SDK’s. No maps are applied.
- Maps left in the package. Larger downloads and source exposure.
- One set of maps for different browser bundles. Wrong lines.
- Never verifying. Broken symbolication goes unnoticed for months.
Cross-browser variation
- Chrome / Edge: IDs are stable for store installs but differ for unpacked and other channels — normalise anyway.
- Firefox: UUIDs are random per install; normalisation is mandatory.
- Safari: UUID-based URLs; normalise the same way.
Verification
- Throw a test error from each context in a packaged build and confirm original files and lines.
- Install the Firefox build and confirm its errors symbolicate too.
- Confirm the release ZIP contains no
.mapfiles. - Bump the version without uploading maps and confirm the dashboard shows minified frames — proving the upload step matters.
FAQ
Do I need sourcesContent?
Not for file and line, but it enables code context in the error UI. Without it, the service needs access to your repository.
Can I upload maps after the release?
Yes, as long as they are from the exact same build. Events received before the upload may stay unsymbolicated, depending on the service.
What about errors in injected func code?
Functions passed to scripting.executeScript are serialised and run without a source URL, so their frames show as anonymous. Keep injected functions small, or inject files instead.
Does normalising URLs lose information?
Only the installation ID, which is not useful for debugging. If you need to tell builds apart, use the release and distribution tags.
Related
- Wiring Sentry into a Manifest V3 extension — the SDK side.
- Debugging production builds with source maps — local use of the same maps.
- Grouping and deduplicating extension errors — after symbolication.
- Error monitoring and crash reporting — the parent topic.