feat(sentry): upload browser source maps for symbolicated JS crashes (#5027)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

Production JS crashes report minified chunk names with no source lines, which
blocked triaging most reader/TTS Sentry issues. Generate browser source maps for
the Tauri export build and upload them right after `next build`:

- next.config: productionBrowserSourceMaps for the export build.
- scripts/upload-sourcemaps.mjs: sentry-cli injects debug IDs + uploads the maps
  (release Readest@<version>, ~/_next/static url-prefix), then strips the .map
  files so they never ship in the app bundle. Token-gated; never fails the build.
- CI (release.yml, nightly.yml): pass SENTRY_AUTH_TOKEN / SENTRY_ORG /
  SENTRY_PROJECT alongside SENTRY_DSN.
- docs/sentry-symbolication.md: setup + required secret, and the native
  (Android ProGuard/.so, iOS dSYM) follow-up.

Local and fork builds are unaffected: with no token the maps are stripped and
the output is unchanged. Needs the SENTRY_AUTH_TOKEN GitHub secret to take effect.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-07-09 16:18:48 +09:00 committed by GitHub
parent 15cca23773
commit 870a62e0e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 297 additions and 1 deletions

View file

@ -0,0 +1,68 @@
# Sentry symbolication (source maps + debug files)
Crash reports from production carry minified JS chunk names (`w`, `#b`,
`chunks/05.7xrfq2vajv.js`) and unsymbolicated native frames. To turn those into
real file:line stacks, Sentry needs the browser **source maps** and the native
**debug files** for each release.
## Prerequisite: `SENTRY_AUTH_TOKEN`
Create an **org auth token** in Sentry (Settings -> Auth Tokens, scope
`project:releases` + `org:read`) for org `readest`, then add it as the GitHub
Actions secret **`SENTRY_AUTH_TOKEN`** (used by `release.yml` and `nightly.yml`).
`SENTRY_ORG` (`readest`) and `SENTRY_PROJECT` (`readest`) are written alongside
`SENTRY_DSN` into the build's `.env.local`.
Nothing below runs without this token: local and fork builds are unaffected.
## Browser JS source maps (implemented)
Covers every `platform: javascript` issue (the reader/TTS crashes on
`tauri.localhost`).
- `next.config.mjs` sets `productionBrowserSourceMaps` for the Tauri **export**
build, so `next build` emits `.js.map` files next to the chunks in
`out/_next/static`.
- `scripts/upload-sourcemaps.mjs` runs right after `next build` (it is chained
into the `build` script). It:
1. `sentry-cli sourcemaps inject` — writes a Sentry debug ID into each chunk
and its map (host-agnostic matching; chunks are served from
`tauri.localhost`, which has no stable URL).
2. `sentry-cli sourcemaps upload` — uploads the maps, associated with release
`Readest@<package.json version>` (matches `sentry_config.rs::sentry_release`)
and a `~/_next/static` URL prefix as a fallback matcher.
3. Deletes every `.js.map` so maps never ship inside the app bundle.
- Any Sentry failure is logged but never fails the build.
Validate on the next nightly: open a symbolicated JS issue and confirm real
file:line frames appear.
## Native debug files (not yet enabled — follow-up)
The credentials + env are already wired for these jobs; each still needs a small,
build-touching change that must be validated on a real native build (the Android
and iOS builds are fragile — see the build gotchas in project memory), so they
are intentionally left as a focused follow-up rather than shipped unverified.
### Android
- **Kotlin/Java stacks + ANRs** (e.g. `RustWebViewClient in shouldOverride`)
need the **Sentry Android Gradle plugin** (`io.sentry.android.gradle`) in
`gen/android/app/build.gradle.kts`: it embeds the ProGuard mapping UUID into
the app and auto-uploads `mapping.txt` on release builds. A bare
`sentry-cli upload-proguard` cannot work without the embedded UUID.
- **Native (`.so`) crashes** (`android::Looper::pollInner`,
`Java_..._WryActivity_create`) need the release build to keep unstripped debug
info (`ndk.debugSymbolLevel = "full"` or an unstripped variant), then
`sentry-cli debug-files upload` the `.so` files.
### iOS
- Upload dSYMs after the Xcode archive with
`sentry-cli debug-files upload --include-sources <archive>/dSYMs`. The App
Store release runs from a local script, so add it there (or to the CI archive
step if one is added).
## Local release builds
The maintainer's local `.env.local` already carries `SENTRY_DSN`; add
`SENTRY_AUTH_TOKEN` (+ optionally `SENTRY_ORG`/`SENTRY_PROJECT`, which default to
`readest`) there and `pnpm build` uploads JS source maps automatically.

View file

@ -28,6 +28,10 @@ const nextConfig = {
// tree (see Dockerfile) so it can ship only the traced runtime; all other
// web builds fall back to the default server output.
output: exportOutput ? 'export' : standaloneOutput ? 'standalone' : undefined,
// Emit browser source maps for the Tauri export build so Sentry can
// symbolicate crashes. `scripts/upload-sourcemaps.mjs` uploads them after the
// build and strips the .map files, so they never ship inside the app bundle.
productionBrowserSourceMaps: exportOutput,
// Monorepo: trace from the repo root so workspace packages land in the
// standalone tree. Only relevant to — and only set for — the Docker build.
outputFileTracingRoot: standaloneOutput ? path.join(__dirname, '../../') : undefined,

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
"build": "dotenv -e .env.tauri -- next build",
"build": "dotenv -e .env.tauri -- next build && node scripts/upload-sourcemaps.mjs",
"start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev",
"build-web": "dotenv -e .env.web -- next build",
@ -214,6 +214,7 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@playwright/test": "^1.60.0",
"@sentry/cli": "^2.39.1",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.10.1",
"@testing-library/dom": "^10.4.0",

View file

@ -0,0 +1,96 @@
// Sentry browser source maps for the Tauri export build.
//
// Runs after `next build` (see the `build` script). `productionBrowserSourceMaps`
// makes Next emit `.js.map` files next to the chunks in `out/_next/static`; this
// script injects Sentry debug IDs into the chunks + maps, uploads the maps, then
// strips the `.map` files so they never ship inside the app bundle.
//
// The injected debug IDs let Sentry symbolicate regardless of the served host
// (chunks load from `tauri.localhost`), and we also associate the upload with the
// release (`Readest@<version>`, matching sentry_config.rs::sentry_release) and a
// host-agnostic `~/_next/static` URL prefix as a fallback matcher.
//
// No-op when SENTRY_AUTH_TOKEN is absent (local + fork builds): the maps are
// still stripped so the output is identical to before. Any Sentry failure is
// logged but never fails the build — crash reporting must not block a release.
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const staticDir = path.join(appDir, 'out', '_next', 'static');
// Resolve a config value from the process env, falling back to `.env.local` then
// `.env` (mirrors how build.rs resolves SENTRY_DSN).
const readEnv = (key) => {
if (process.env[key]) return process.env[key];
for (const file of ['.env.local', '.env']) {
const p = path.join(appDir, file);
if (!existsSync(p)) continue;
for (const line of readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && m[1] === key) return m[2].replace(/^["']|["']$/g, '').trim();
}
}
return '';
};
// Recursively delete every `*.js.map` under `dir`.
const stripMaps = (dir) => {
if (!existsSync(dir)) return 0;
let removed = 0;
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) removed += stripMaps(full);
else if (entry.endsWith('.js.map')) {
rmSync(full);
removed += 1;
}
}
return removed;
};
if (!existsSync(staticDir)) {
// Not an export build (e.g. `build-web`); nothing to do.
process.exit(0);
}
const authToken = readEnv('SENTRY_AUTH_TOKEN');
if (authToken) {
const org = readEnv('SENTRY_ORG') || 'readest';
const project = readEnv('SENTRY_PROJECT') || 'readest';
const require = createRequire(import.meta.url);
const version = require(path.join(appDir, 'package.json')).version;
const release = `Readest@${version}`;
// The sentry-cli binary shipped by the @sentry/cli package.
const bin = require('@sentry/cli').getPath();
const env = { ...process.env, SENTRY_AUTH_TOKEN: authToken, SENTRY_ORG: org, SENTRY_PROJECT: project };
const run = (args) => execFileSync(bin, args, { cwd: appDir, env, stdio: 'inherit' });
try {
run(['sourcemaps', 'inject', staticDir]);
run([
'sourcemaps',
'upload',
'--release',
release,
'--url-prefix',
'~/_next/static',
staticDir,
]);
console.log(`Sentry: uploaded source maps for ${release}.`);
} catch (err) {
// Never fail the build over crash-reporting plumbing.
console.warn('Sentry: source map upload failed, continuing build:', err?.message ?? err);
}
} else {
console.log('Sentry: SENTRY_AUTH_TOKEN unset, skipping source map upload.');
}
const removed = stripMaps(staticDir);
console.log(`Sentry: stripped ${removed} .js.map file(s) from the app bundle.`);