From 8b41efec55756831ad49beafd02f6131a459b642 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 30 Jun 2026 12:36:23 +0100 Subject: [PATCH] feat(sentry): symbolicate backend errors in debug builds without uploading sourcemaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug builds now ship the embedded Node backend's sourcemap alongside the bundle and pass Node's `--enable-source-maps`, so backend errors are remapped to original positions in-process and reach Sentry symbolicated — no sourcemap upload or Sentry auth token needed. Release builds are unchanged: maps stay out of the app and are uploaded by the consumer via `comapeo-rn-upload-sourcemaps`. Android ships the debug map colocated in the `src/debug` asset tree (debug variants only). iOS uses a Debug-only `ComapeoCoreSourcemaps` companion pod (`:configurations => ['Debug']`) whose maps merge next to the bundle in the app; `expo-module.config.json` pins `apple.podspecPath` so it is never autolinked. The native side passes `--enable-source-maps` under `BuildConfig.DEBUG` / `#if DEBUG`. --- .gitignore | 1 - CONTRIBUTING.md | 17 ++++++++ README.md | 11 +++-- .../java/com/comapeo/core/NodeJSService.kt | 13 +++++- app.plugin.js | 40 ++++++++++++++++++- backend/rolldown.config.ts | 19 +++++---- expo-module.config.json | 1 + ios/ComapeoCoreSourcemaps.podspec | 37 +++++++++++++++++ ios/NodeJSService.swift | 16 ++++++-- package.json | 1 + scripts/build-backend.ts | 19 ++++++--- src/cli/upload-sourcemaps.ts | 24 +++++------ 12 files changed, 160 insertions(+), 39 deletions(-) create mode 100644 ios/ComapeoCoreSourcemaps.podspec diff --git a/.gitignore b/.gitignore index f14fd1e2..7a7e425d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,6 @@ ios/.swiftpm/ backend/dist nodejs-assets android/src/debug/assets/ -android/src/debug/nodejs-sourcemaps/ android/src/main/assets/ android/src/main/jniLibs/ android/src/main/nodejs-sourcemaps/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 153d6f64..ed92f6c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,23 @@ Re-run `npm run setup` after pulling changes that bump the nodejs-mobile version or touch the backend. The individual steps are also available on their own (`npm run download:nodejs-mobile`, `npm run backend:build`). +### Backend sourcemaps + +`backend:build` symbolicates backend errors two ways, split by build type so +release artifacts stay lean: + +- **Debug builds** ship the bundle's sourcemap colocated with it and pass Node's + `--enable-source-maps` flag (Android `BuildConfig.DEBUG`, iOS `#if DEBUG`), so + errors are remapped to original positions in-process and reach Sentry already + symbolicated — no upload or Sentry auth token needed. On Android the map rides + the `src/debug/` asset tree (debug variants only); on iOS the config plugin + adds a Debug-only `ComapeoCoreSourcemaps` companion pod + (`:configurations => ['Debug']`) whose maps merge next to the bundle in the + app — no custom build phase. +- **Release builds** keep the map out of the app entirely; the consuming app + uploads it to Sentry from CI with `comapeo-rn-upload-sourcemaps` (debug-ID + matched, symbolicated server-side). + ## Repository layout This is a single published npm package with its build tooling and test apps in diff --git a/README.md b/README.md index 48c288e0..aa154e6b 100644 --- a/README.md +++ b/README.md @@ -355,12 +355,15 @@ Re-uploading is idempotent (Sentry de-dupes by debug ID). The CLI finds `@sentry/cli` via `@sentry/react-native`'s dependency chain — if you don't have `@sentry/react-native` installed, add `@sentry/cli` to your devDependencies. `--targets ` restricts the upload to a subset of -`android-debug, android-main, ios`; `--url` points at self-hosted Sentry; +`android-main, ios`; `--url` points at self-hosted Sentry; `SENTRY_ORG` / `SENTRY_PROJECT` work in place of the flags. -The backend sourcemaps live in sibling `nodejs-sourcemaps/` directories (not -under the bundled `nodejs-project/` assets), so they are **not** shipped inside -your APK/IPA — no exclusion step is needed to keep them off the device. +Only **release** builds need this upload. Debug builds ship the backend +sourcemap alongside the bundle and enable Node's `--enable-source-maps`, so +backend errors are symbolicated in-process — no upload or Sentry auth token +required. In release builds the maps live in sibling `nodejs-sourcemaps/` +directories (not under the bundled `nodejs-project/` assets), so they are +**not** shipped inside your release APK/IPA. #### JS bundle sourcemaps and native debug symbols (your app) diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index 6fcc7e50..1b41d546 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -357,8 +357,17 @@ class NodeJSService( // 5th positional: consumer's online map style URL, or "" when unset. val defaultOnlineStyleUrl = SentryConfig.readApplicationMetaDataString(this, META_DEFAULT_ONLINE_STYLE_URL) ?: "" - val args = mutableListOf( - "node", + val args = mutableListOf("node") + // Debug builds ship the backend's `.map` colocated with the bundle + // (src/debug only). `--enable-source-maps` (a Node runtime flag, so + // it must precede the script path) makes Node remap stacks to + // original positions in-process, so Sentry events are symbolicated + // without a map upload. Release builds omit it and rely on + // consumer-uploaded maps (debug-ID matched, symbolicated by Sentry). + if (BuildConfig.DEBUG) { + args += "--enable-source-maps" + } + args += listOf( entryPath, comapeoSocketFile.absolutePath, controlSocketFile.absolutePath, diff --git a/app.plugin.js b/app.plugin.js index 2b8f9809..91e26573 100644 --- a/app.plugin.js +++ b/app.plugin.js @@ -27,7 +27,8 @@ import configPlugins from "@expo/config-plugins"; import { createRequire } from "node:module"; import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; const { withAndroidManifest } = configPlugins; const { withMainApplication } = configPlugins; const { withInfoPlist } = configPlugins; @@ -95,6 +96,12 @@ function withComapeoCore(config, props) { config = withSentryAndroid(config, sentry, moduleIdent); config = withSentryIos(config, sentry); config = withSentryLibraryEvolution(config); + // iOS Debug builds: ship the backend sourcemaps next to the bundle so + // Node's `--enable-source-maps` (passed by NodeJSService under #if DEBUG) + // symbolicates backend errors in-process — no Sentry upload needed. + // Release builds skip the copy (the bundle's map stays out of the IPA). + // Android handles the debug case via its `src/debug/` asset tree instead. + config = withDebugSourcemapsIos(config); // Optional default project config (presets/categories) supplied by the // consuming app. The module no longer ships @comapeo/default-categories; // when this prop is absent, new projects get no default config. @@ -413,6 +420,37 @@ function withSentryLibraryEvolution(config) { }); } +// Adds the Debug-only `ComapeoCoreSourcemaps` companion pod to the consumer's +// Podfile. `:configurations => ['Debug']` makes CocoaPods copy its resources +// (the backend `.map` files, shaped as a `nodejs-project` dir) only in Debug +// builds — they merge next to the bundle in the `.app`, where Node resolves +// them via `--enable-source-maps` (passed by NodeJSService under #if DEBUG). +// Release builds never include the pod, so the IPA stays lean. +// +// The pod is added explicitly here, never autolinked — see +// `ComapeoCoreSourcemaps.podspec` and the pinned `apple.podspecPath` in +// `expo-module.config.json`. `:path` is computed per-consumer so it survives +// monorepo node_modules hoisting. +function withDebugSourcemapsIos(config) { + return withPodfile(config, (cfg) => { + const podDir = join(dirname(fileURLToPath(import.meta.url)), "ios"); + const relPodPath = + relative(cfg.modRequest.platformProjectRoot, podDir) || "."; + const podLine = + ` pod 'ComapeoCoreSourcemaps', :path => '${relPodPath}', ` + + `:configurations => ['Debug']`; + cfg.modResults.contents = mergeContents({ + tag: "comapeo-core-debug-sourcemaps", + src: cfg.modResults.contents, + newSrc: podLine, + anchor: /use_expo_modules!/, + offset: 1, + comment: "#", + }).contents; + return cfg; + }); +} + /** * Module version label + bundled-backend dep map — the same values * `src/version.ts` exposes to the RN-side `initSentry`. Used only on diff --git a/backend/rolldown.config.ts b/backend/rolldown.config.ts index 5c5127c8..89f2c9ec 100644 --- a/backend/rolldown.config.ts +++ b/backend/rolldown.config.ts @@ -47,10 +47,11 @@ const IOS_OUT = process.env.OUTPUT_DIR_IOS ?? path.join(__dirname, "dist/ios"); * For the production build these are passed by `scripts/build-backend.ts`; * the fallbacks keep the standalone `cd backend && npm run build` case * working — maps land in `-sourcemaps/` next to the bundle dir. + * + * Note: the Android *debug* output is deliberately absent — its map stays + * colocated with the bundle (shipped in debug-only `src/debug/` for + * in-process symbolication), so there's nothing to relocate. */ -const ANDROID_SOURCEMAPS_DEBUG = - process.env.SOURCEMAPS_DIR_ANDROID_DEBUG ?? `${ANDROID_OUT_DEBUG}-sourcemaps`; - const ANDROID_SOURCEMAPS_MAIN = process.env.SOURCEMAPS_DIR_ANDROID_MAIN ?? `${ANDROID_OUT_MAIN}-sourcemaps`; @@ -316,11 +317,13 @@ const config: RolldownOptions[] = [ outDir: ANDROID_OUT_DEBUG, debugIdMap: androidDebugDebugIds, }), - relocateSourcemapsPlugin( - ANDROID_OUT_DEBUG, - ANDROID_SOURCEMAPS_DEBUG, - androidDebugDebugIds, - ), + // Debug output keeps its `.map` colocated with the bundle (no + // relocate). `src/debug/` is merged only into debug variants, so the + // map ships in debug APKs but never in release. With native passing + // `--enable-source-maps` under `BuildConfig.DEBUG`, Node remaps + // backend stacks in-process — symbolicated errors reach Sentry with + // no upload or auth token. Release uses `src/main` (relocated + + // consumer-uploaded). ], }, { diff --git a/expo-module.config.json b/expo-module.config.json index 9ef0d7df..211ef11b 100644 --- a/expo-module.config.json +++ b/expo-module.config.json @@ -1,6 +1,7 @@ { "platforms": ["apple", "android"], "apple": { + "podspecPath": "ios/ComapeoCore.podspec", "modules": [ "ComapeoCoreModule" ], diff --git a/ios/ComapeoCoreSourcemaps.podspec b/ios/ComapeoCoreSourcemaps.podspec new file mode 100644 index 00000000..e0df8202 --- /dev/null +++ b/ios/ComapeoCoreSourcemaps.podspec @@ -0,0 +1,37 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +# Debug-only companion pod: ships the embedded backend bundle's sourcemaps. +# +# The config plugin (app.plugin.js) adds this to the consumer's Podfile with +# `:configurations => ['Debug']`, so CocoaPods copies these resources only in +# Debug builds — Release IPAs never carry the (multi-MB) maps. Native passes +# Node's `--enable-source-maps` under `#if DEBUG`, so backend errors are +# remapped to original positions in-process and reach Sentry symbolicated with +# no upload. Release builds instead rely on the consumer uploading the same +# maps via `comapeo-rn-upload-sourcemaps` (debug-ID matched, server-side). +# +# Not autolinked: `expo-module.config.json` pins `apple.podspecPath` to the +# main `ComapeoCore.podspec`, so Expo only links that one and this pod is only +# ever added via the explicit, config-scoped Podfile entry above. +Pod::Spec.new do |s| + s.name = 'ComapeoCoreSourcemaps' + s.version = package['version'] + s.summary = 'Debug-only backend sourcemaps for in-process symbolication' + s.description = 'Ships @comapeo/core-react-native backend sourcemaps as a Debug-only resource.' + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { :ios => '16.4' } + s.source = { git: 'https://github.com/digidem/comapeo-core-react-native' } + + # Maps are laid out as a `nodejs-project/` dir (mirroring the bundle: the + # `relocateSourcemapsPlugin` writes `index.mjs.map`, `loader.mjs.map`, + # `chunks/*.map`). CocoaPods copies `nodejs-project` into the app bundle + # root, where it MERGES with the main pod's `nodejs-project` (the resource + # rsync runs without `--delete`, so the bundle files survive). Each map then + # sits next to its bundle file, so Node resolves the relative + # `//# sourceMappingURL=index.mjs.map` at runtime. + s.resources = ['nodejs-sourcemaps/nodejs-project'] +end diff --git a/ios/NodeJSService.swift b/ios/NodeJSService.swift index 829b3444..cc83f345 100644 --- a/ios/NodeJSService.swift +++ b/ios/NodeJSService.swift @@ -628,16 +628,24 @@ class NodeJSService { let defaultConfigPath = resolveDefaultConfigPath() ?? "" // 5th positional: consumer's online map style URL, or "" when unset. let defaultOnlineStyleUrl = resolveDefaultOnlineStyleUrl() ?? "" - var args: [String] = [ - "node", - "--no-experimental-fetch", + var args: [String] = ["node", "--no-experimental-fetch"] + // Debug builds ship the backend's `.map` next to the bundle via the + // Debug-only `ComapeoCoreSourcemaps` companion pod. `--enable-source-maps` + // (a Node runtime flag, so it precedes the script path) makes Node + // remap stacks to original positions in-process, so Sentry events + // are symbolicated without a map upload. Release omits it and relies + // on consumer-uploaded maps (debug-ID matched, symbolicated by Sentry). + #if DEBUG + args.append("--enable-source-maps") + #endif + args.append(contentsOf: [ jsPath, comapeoSocketPath, controlSocketPath, privateStorageDir, defaultConfigPath, defaultOnlineStyleUrl, - ] + ]) args.append(contentsOf: buildSentryArgs()) let exitCode = nodeEntryPoint(args) logCrumb( diff --git a/package.json b/package.json index 9a26031a..ca473ef7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "ios/*.{h,m,mm,swift,hpp,cpp}", "!ios/Package.swift", "ios/ComapeoCore.podspec", + "ios/ComapeoCoreSourcemaps.podspec", "ios/NodeMobile.xcframework", "ios/Frameworks/", "ios/nodejs-project/", diff --git a/scripts/build-backend.ts b/scripts/build-backend.ts index d68062ba..486c2957 100755 --- a/scripts/build-backend.ts +++ b/scripts/build-backend.ts @@ -38,15 +38,23 @@ const IOS_NODEJS_PROJECT_DIR = join(PROJECT_ROOT, "ios/nodejs-project"); // APK/IPA. The relocate-sourcemaps rolldown plugin moves `*.map` here // after writeBundle. Consumed by `comapeo-rn-upload-sourcemaps` at // the consumer's CI step. -const ANDROID_DEBUG_SOURCEMAPS_DIR = join( - PROJECT_ROOT, - "android/src/debug/nodejs-sourcemaps", -); +// +// The Android *debug* output has no relocation target: its map stays +// colocated with the bundle in `src/debug/assets/nodejs-project/` (shipped +// only in debug variants) so Node can remap stacks in-process. See +// `backend/rolldown.config.ts`. const ANDROID_MAIN_SOURCEMAPS_DIR = join( PROJECT_ROOT, "android/src/main/nodejs-sourcemaps", ); -const IOS_SOURCEMAPS_DIR = join(PROJECT_ROOT, "ios/nodejs-sourcemaps"); +// iOS maps are laid out under a `nodejs-project/` dir so the Debug-only +// `ComapeoCoreSourcemaps` companion pod can ship them as a `nodejs-project` +// resource that merges next to the bundle in the app (see that podspec and +// `app.plugin.js`'s `withDebugSourcemapsIos`). +const IOS_SOURCEMAPS_DIR = join( + PROJECT_ROOT, + "ios/nodejs-sourcemaps/nodejs-project", +); // One xcframework per native module instance. CocoaPods picks them up // via `vendored_frameworks` in ComapeoCore.podspec; Xcode's standard // Embed & Sign phase places + codesigns them into .app/Frameworks/ @@ -90,7 +98,6 @@ await $({ OUTPUT_DIR_ANDROID_DEBUG: ANDROID_DEBUG_NODEJS_PROJECT_DIR, OUTPUT_DIR_ANDROID_MAIN: ANDROID_MAIN_NODEJS_PROJECT_DIR, OUTPUT_DIR_IOS: IOS_NODEJS_PROJECT_DIR, - SOURCEMAPS_DIR_ANDROID_DEBUG: ANDROID_DEBUG_SOURCEMAPS_DIR, SOURCEMAPS_DIR_ANDROID_MAIN: ANDROID_MAIN_SOURCEMAPS_DIR, SOURCEMAPS_DIR_IOS: IOS_SOURCEMAPS_DIR, }, diff --git a/src/cli/upload-sourcemaps.ts b/src/cli/upload-sourcemaps.ts index f8d5a8c3..26afdefb 100644 --- a/src/cli/upload-sourcemaps.ts +++ b/src/cli/upload-sourcemaps.ts @@ -4,14 +4,17 @@ // of the release pipeline). Re-uploading is idempotent — Sentry de-dupes // by debug ID. // -// Three targets ship with the package: +// Only the *release* bundles are uploaded — debug builds ship their map +// alongside the bundle and symbolicate in-process via Node's +// `--enable-source-maps` (see `backend/rolldown.config.ts` and the native +// NodeJSService), so there's nothing to upload for them. +// +// Two targets ship with the package: // -// android-debug → android/src/debug/assets/nodejs-project/index.mjs -// android/src/debug/nodejs-sourcemaps/index.mjs.map // android-main → android/src/main/assets/nodejs-project/index.mjs // android/src/main/nodejs-sourcemaps/index.mjs.map // ios → ios/nodejs-project/index.mjs -// ios/nodejs-sourcemaps/index.mjs.map +// ios/nodejs-sourcemaps/nodejs-project/index.mjs.map // // Each (bundle, map) pair is keyed by a deterministic debug ID embedded // at build time (`stringToUUID(chunk.code)`); see @@ -44,11 +47,6 @@ interface Target { const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const TARGETS: Record = { - "android-debug": { - name: "android-debug", - bundleDir: join(PKG_ROOT, "android/src/debug/assets/nodejs-project"), - sourcemapDir: join(PKG_ROOT, "android/src/debug/nodejs-sourcemaps"), - }, "android-main": { name: "android-main", bundleDir: join(PKG_ROOT, "android/src/main/assets/nodejs-project"), @@ -57,11 +55,11 @@ const TARGETS: Record = { ios: { name: "ios", bundleDir: join(PKG_ROOT, "ios/nodejs-project"), - sourcemapDir: join(PKG_ROOT, "ios/nodejs-sourcemaps"), + sourcemapDir: join(PKG_ROOT, "ios/nodejs-sourcemaps/nodejs-project"), }, }; -const DEFAULT_TARGETS = "android-debug,android-main,ios"; +const DEFAULT_TARGETS = "android-main,ios"; const USAGE = `\ Usage: comapeo-rn-upload-sourcemaps [options] @@ -73,8 +71,8 @@ Options: --org Sentry org slug (or SENTRY_ORG env var). --project Sentry project slug (or SENTRY_PROJECT env var). --url Sentry instance URL (default sentry.io; or SENTRY_URL). - --targets Comma-separated subset of: android-debug, android-main, - ios. Default: all three. + --targets Comma-separated subset of: android-main, ios. + Default: both. -h, --help Show this help. Required env: