From fb84fbf16af13a3a3742bd69d471e780dbaf725b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 17 Jul 2026 10:31:23 -0400 Subject: [PATCH 1/7] build: emit hidden source maps and keep them out of app bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable Vite `build.sourcemap: 'hidden'` so the production build emits `.map` files (for later upload to Sentry) without shipping a `//# sourceMappingURL=` comment in the bundle. Strip `*.map` from the iOS and Android native bundles in `copy-dist-ios`/`copy-dist-android` so maps never ship inside the app — they'd bloat the binary and expose de-minified source — while `dist/` retains them for the delivery/upload step. Groundwork for Sentry source-map symbolication of editor exceptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 +++++++++ vite.config.js | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/Makefile b/Makefile index 3edeeebac..f38c3e2a4 100644 --- a/Makefile +++ b/Makefile @@ -139,17 +139,26 @@ build: prep-translations ## Build the project for all platforms (iOS, Android, w echo "--- :white_check_mark: Skipping JS build (dist already exists). Use REFRESH_JS_BUILD=1 to force refresh."; \ fi +# The `find ... -delete` after each copy strips source maps from the native +# bundles. Maps are emitted into `dist/` (see `vite.config.js`) so build tooling +# can deliver them to Sentry, but they must NOT ship inside the app: they'd bloat +# the binary and expose de-minified source. `dist/` itself keeps its maps for the +# upload step. Stripping here (rather than only in CI) covers every consumer of +# these targets — local `make build`, CI, and `bin/release.sh` (which commits the +# iOS bundle via `git add .`). .PHONY: copy-dist-ios copy-dist-ios: @rm -rf ./ios/Sources/GutenbergKitResources/Gutenberg/ @mkdir -p ./ios/Sources/GutenbergKitResources/Gutenberg @cp -r ./dist/. ./ios/Sources/GutenbergKitResources/Gutenberg/ + @find ./ios/Sources/GutenbergKitResources/Gutenberg -name '*.map' -delete @touch ./ios/Sources/GutenbergKitResources/Gutenberg/.gitkeep .PHONY: copy-dist-android copy-dist-android: @rm -rf ./android/Gutenberg/src/main/assets/ @cp -r ./dist/. ./android/Gutenberg/src/main/assets + @find ./android/Gutenberg/src/main/assets -name '*.map' -delete .PHONY: build-swift-package build-swift-package: build ## Build the Swift package for iOS diff --git a/vite.config.js b/vite.config.js index c855406fc..6c70d7e99 100644 --- a/vite.config.js +++ b/vite.config.js @@ -24,6 +24,13 @@ export default defineConfig( { build: { outDir: '../dist', target: 'esnext', + // Emit source maps so build tooling can upload them to Sentry for + // exception symbolication. `'hidden'` writes the `.map` files but omits + // the `//# sourceMappingURL=` comment from the shipped JS: the maps are + // stripped from the native bundles (see the Makefile) so a comment + // pointing at them would dangle. Dev-server debugging is unaffected — + // `build.sourcemap` applies only to production builds, not `vite dev`. + sourcemap: 'hidden', }, resolve: { alias: { From c49f77e4ad04d6b5ea4c6249103f5672f63603d5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 17 Jul 2026 14:18:50 -0400 Subject: [PATCH 2/7] build: package and publish GutenbergKit source maps to S3 In the `build-react` CI step, package Vite's emitted `.map` files into `GutenbergKitSourceMaps.zip` (preserving the `assets/` layout), then strip the maps from `dist/` before creating `dist.tar.gz`. Stripping here keeps the maps out of every app bundle even for downstream steps (e.g. the Android publish) that copy `dist/` directly rather than through the map-stripping Makefile targets. Upload the zip to `gutenbergkit//GutenbergKitSourceMaps.zip` from `publish_to_s3`, alongside the XCFramework. The maps ship as a separate S3 object rather than inside the XCFramework because SwiftPM binary-target consumers don't surface sibling files to the host build; host apps fetch the zip by GutenbergKit version and upload it to Sentry for exception symbolication. Publishing is optional end to end: the artifact downloads tolerate a missing zip and `publish_to_s3` skips the upload when it's absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .buildkite/pipeline.yml | 14 ++++++++++++++ .buildkite/release.sh | 5 +++++ fastlane/Fastfile | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 3722af2a3..6fc85cf89 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -35,6 +35,17 @@ steps: exit "$$PREP_STATUS" fi make build REFRESH_JS_BUILD=1 + # Vite emits `.map` files into `dist/` for Sentry symbolication. + # Zip them (preserving the `assets/` layout the host uploads under), + # then strip them from `dist/` BEFORE tarring: the source maps must + # never ship inside the app bundles, and some downstream steps (e.g. + # the Android publish) copy `dist/` directly rather than through the + # map-stripping Makefile targets. Stripping here makes `dist.tar.gz` + # map-free for every consumer. + echo "--- :sentry: Packaging source maps" + ( cd dist && zip -r -q ../GutenbergKitSourceMaps.zip . -i '*.map' ) + buildkite-agent artifact upload GutenbergKitSourceMaps.zip + find dist -name '*.map' -delete tar -czf dist.tar.gz dist/ buildkite-agent artifact upload dist.tar.gz plugins: &plugins @@ -149,6 +160,9 @@ steps: command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . + # Source maps are optional: `publish_to_s3` skips the upload when the + # zip is absent, so don't fail the step if the artifact isn't found. + buildkite-agent artifact download GutenbergKitSourceMaps.zip . || true install_gems bundle exec fastlane publish_to_s3 version:$BUILDKITE_COMMIT plugins: *plugins diff --git a/.buildkite/release.sh b/.buildkite/release.sh index 9e65687e2..077b193cd 100755 --- a/.buildkite/release.sh +++ b/.buildkite/release.sh @@ -16,6 +16,11 @@ echo '--- :arrow_down: Downloading XCFramework artifacts' buildkite-agent artifact download '*.xcframework.zip' . --step "build-xcframework" buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . --step "build-xcframework" +# Source maps are optional: `publish_to_s3` skips the upload when the zip is +# absent, so tolerate a missing artifact (e.g. a build that emitted no maps). +echo '--- :arrow_down: Downloading source maps' +buildkite-agent artifact download GutenbergKitSourceMaps.zip . --step "build-react" || true + echo '--- :rubygems: Setting up Gems' install_gems diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1b9ef3d35..5b135b285 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -48,6 +48,25 @@ lane :publish_to_s3 do |options| auto_prefix: false, if_exists: :replace ) + + # Publish the JS source maps alongside the XCFramework so host apps can fetch + # them (by GutenbergKit version) and upload them to Sentry for exception + # symbolication. The maps are NOT bundled inside the XCFramework — SwiftPM + # binary-target consumers don't surface sibling files to the host build — so + # they ship as a separate object under the same versioned key prefix. + # Optional: older builds (or a build that emitted no maps) simply skip this. + sourcemaps = sourcemaps_file_path + if sourcemaps + upload_to_s3( + bucket: 'a8c-apps-public-artifacts', + key: File.join('gutenbergkit', version, 'GutenbergKitSourceMaps.zip'), + file: sourcemaps, + auto_prefix: false, + if_exists: :replace + ) + else + UI.important('No GutenbergKitSourceMaps.zip found; skipping source-map upload.') + end end lane :publish_pr_xcframework do @@ -224,6 +243,15 @@ def xcframework_file_path ) end +# Locate the source-map bundle produced by the `build-react` CI step. Returns +# nil when absent (rather than failing) so `publish_to_s3` can treat source-map +# publishing as optional — a build that emitted no maps still publishes the +# XCFramework. +def sourcemaps_file_path + path = File.join(PROJECT_ROOT, 'GutenbergKitSourceMaps.zip') + File.exist?(path) ? path : nil +end + def require_single_glob_match!(pattern, not_found_message) matches = Dir.glob(pattern) From efe442e61ee3f00da668d7b65cddadc98126c07c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 17 Jul 2026 14:33:59 -0400 Subject: [PATCH 3/7] build: publish source maps for PR XCFramework builds too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR XCFramework publish path (`publish-pr-xcframework.sh` → `publish_pr_xcframework`) also calls `publish_to_s3`, but didn't download the source-map artifact, so it logged "skipping source-map upload." Download the zip here as well so PR builds publish their maps under `gutenbergkit/pr-builds//`, consistent with the release and per-commit paths. The download tolerates a missing artifact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .buildkite/publish-pr-xcframework.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.buildkite/publish-pr-xcframework.sh b/.buildkite/publish-pr-xcframework.sh index a25b03543..428922db6 100755 --- a/.buildkite/publish-pr-xcframework.sh +++ b/.buildkite/publish-pr-xcframework.sh @@ -21,6 +21,11 @@ echo '--- :arrow_down: Downloading XCFramework artifacts' buildkite-agent artifact download '*.xcframework.zip' . --step "build-xcframework" buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . --step "build-xcframework" +# Source maps are optional: `publish_to_s3` skips the upload when the zip is +# absent, so tolerate a missing artifact (e.g. a build that emitted no maps). +echo '--- :arrow_down: Downloading source maps' +buildkite-agent artifact download GutenbergKitSourceMaps.zip . --step "build-react" || true + echo '--- :rubygems: Setting up Gems' install_gems From 2017cff543e77b45c78df0272be6cbc93afeafd6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 18 Jul 2026 07:43:33 -0400 Subject: [PATCH 4/7] build: inject Sentry Debug IDs into JS and source maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `@sentry/cli` as a dev dependency and run `sentry-cli sourcemaps inject` during `make build`, between the Vite build and the native-bundle copies. This stamps a matching Debug ID into both the shipped JS (via a `//# debugId=` comment + `_sentryDebugIds` global) and the source maps that get uploaded to Sentry. Debug IDs let Sentry associate maps with events regardless of the file path in the stack frame. GutenbergKit's editor runs in a WebView loading files from a per-install bundle directory, so the frame paths contain a random per-install UUID and can never match a fixed uploaded-artifact name — path-based matching cannot work. Debug IDs are the reliable alternative. `sentry-cli` is a build-time dev dependency only; nothing Sentry ships in the bundle. `inject` needs no auth token (uploading stays host-side), and the UUIDs are content-hashed, so builds stay reproducible. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 17 +++++ package-lock.json | 191 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 209 insertions(+) diff --git a/Makefile b/Makefile index f38c3e2a4..a82b51a9a 100644 --- a/Makefile +++ b/Makefile @@ -132,6 +132,7 @@ build: prep-translations ## Build the project for all platforms (iOS, Android, w $(MAKE) _RECURSIVE_INVOKE=1 npm-dependencies && \ echo "--- :node: Building Gutenberg" && \ npm run build && \ + $(MAKE) inject-sourcemap-debug-ids && \ echo "--- :open_file_folder: Copying Build Products into place" && \ $(MAKE) copy-dist-ios && \ $(MAKE) copy-dist-android; \ @@ -139,6 +140,22 @@ build: prep-translations ## Build the project for all platforms (iOS, Android, w echo "--- :white_check_mark: Skipping JS build (dist already exists). Use REFRESH_JS_BUILD=1 to force refresh."; \ fi +# Inject Sentry Debug IDs into the built JS and its source maps. This runs +# BETWEEN `npm run build` and the `copy-dist-*` targets so the `//# debugId=` +# comment lands in `dist/` and is copied into the shipped bundles, while the +# matching `debug_id` is written into the `.map` files that get uploaded to +# Sentry. Debug IDs let Sentry match maps to events regardless of the (unstable, +# per-install) on-device file paths — the only reliable approach for GutenbergKit's +# WebView, where path-based matching fails. +# +# `@sentry/cli sourcemaps inject` only rewrites files with adjacent maps; it needs +# no auth token (uploading, which does, stays host-side). The injected UUIDs are +# deterministic (content-hashed), so builds remain reproducible. +.PHONY: inject-sourcemap-debug-ids +inject-sourcemap-debug-ids: + @echo "--- :sentry: Injecting source map Debug IDs" + @npx sentry-cli sourcemaps inject dist + # The `find ... -delete` after each copy strips source maps from the native # bundles. Maps are emitted into `dist/` (see `vite.config.js`) so build tooling # can deliver them to Sentry, but they must NOT ship inside the app: they'd bloat diff --git a/package-lock.json b/package-lock.json index 60d45275c..d784a2701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@sentry/cli": "^3.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -6495,6 +6496,189 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry/cli": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.6.0.tgz", + "integrity": "sha512-79XC8o59G/i3VqmnoQD74a/QD/422eQQlUqiPd3sEvcYCxnaZialRVAsxuNEFd6sx4aVGpqt775MMDT9cV/lMg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.6.0", + "@sentry/cli-linux-arm": "3.6.0", + "@sentry/cli-linux-arm64": "3.6.0", + "@sentry/cli-linux-i686": "3.6.0", + "@sentry/cli-linux-x64": "3.6.0", + "@sentry/cli-win32-arm64": "3.6.0", + "@sentry/cli-win32-i686": "3.6.0", + "@sentry/cli-win32-x64": "3.6.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.6.0.tgz", + "integrity": "sha512-C2SWHKaEP8NoYkLDr5jwrzklTwlJkzPIx7lu2LZrwBuHG/sNhWdili0ED2mD86b6Q90hlcMtuBm5IHUwcZ6FTQ==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.6.0.tgz", + "integrity": "sha512-S9xsDZTvybOGbrqqZ7DvF7JCNKp4cakDWJ4LdvQX+z82cHQSoLkYOXkA3EafDfWV9BGIRMIXitMMiSsV2PMU4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.6.0.tgz", + "integrity": "sha512-Rc+DB8vuTDpwqY2BxIPnooYk2ZDYQytF+n4nfi6pZZsJtr3SFFe+3wIWVmCVqxiHkaISb33+iJIDxOOqhkSNbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.6.0.tgz", + "integrity": "sha512-PQ7+ctNmWtHgmbKa+rJheHU7D9GHJXafgWYfVW6gt7R0Ag9LxiDVYLGjrL4G/i7AGFNgudXFaLkGKNX7HUjc+g==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.6.0.tgz", + "integrity": "sha512-c+7xNg5BAaPE8N2Q6pg3Q/kt97JSaskuQIjRxHaFuDbCkJvww4VozY6mW5NUMJaW48rEs3mahWKamTLqJsO3wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.6.0.tgz", + "integrity": "sha512-zhZ7YyGreHSKZ92Mwb9h4cEyL0I/eND7W6XIUXUW0BCCmxFOMc71vlQpUw8gijHIsFDbv8c8a6VOSkeRuwbSwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.6.0.tgz", + "integrity": "sha512-JaxzVDdyetrPBp8NHh2yNAYdDk79ROXqfAfjQwG5z6V764MMMrf2WrhQ7EwoKXOPtBLm/drbOcYgaxHuDZKGRw==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.6.0.tgz", + "integrity": "sha512-LW0078VlxaUeVMMLA15A1zhkvZ5vby/lwthtBXPoBSDdTcgbTE4D4gQXM9vEapV28SvCO3fVIek3pbtrkaeDMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -18998,6 +19182,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/ps-man": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/ps-man/-/ps-man-1.1.8.tgz", diff --git a/package.json b/package.json index 999194ac2..3cbe8a56a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ }, "devDependencies": { "@playwright/test": "^1.58.2", + "@sentry/cli": "^3.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", From 49e7f35aabdc0c7c7acd1ce3cac6f7099ed1ab10 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 18 Jul 2026 07:45:53 -0400 Subject: [PATCH 5/7] feat: attach source-map Debug IDs to logged exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the `window._sentryDebugIds` global (populated by the `sentry-cli sourcemaps inject` build step) and emit a `debug_images` array on the exception payload, pairing each stack file with its Debug ID. Reuses the existing stack parsers to resolve which Debug ID belongs to which file. The native bridge forwards this to the crash-logging SDK, which sets it as `debug_meta` on the Sentry event so uploaded source maps match by Debug ID — the reliable path for the editor's unstable WebView file paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/exception-parser.js | 55 +++++++++++++++++- src/utils/exception-parser.test.js | 90 ++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/utils/exception-parser.test.js diff --git a/src/utils/exception-parser.js b/src/utils/exception-parser.js index b5b96389d..0a810fc74 100644 --- a/src/utils/exception-parser.js +++ b/src/utils/exception-parser.js @@ -85,9 +85,62 @@ const parseException = ( originalException ) => { return exception; }; +// Build a map of `filename -> debugId` from the `window._sentryDebugIds` global. +// `sentry-cli sourcemaps inject` adds a snippet to each built chunk that captures +// its own `(new Error).stack` as a key and stores the chunk's Debug ID as the +// value. The top frame of that stack identifies the chunk's file, so parsing each +// key yields which Debug ID belongs to which file. +const getDebugIdsByFilename = () => { + const debugIdMap = + typeof window !== 'undefined' ? window._sentryDebugIds : undefined; + if ( ! debugIdMap ) { + return {}; + } + + const result = {}; + for ( const stack of Object.keys( debugIdMap ) ) { + for ( const line of stack.split( '\n' ) ) { + let frame; + for ( const parser of stackParsers ) { + frame = parser( line ); + if ( frame ) { + break; + } + } + // The first parseable frame's file is the chunk the snippet ran in. + if ( frame?.filename ) { + result[ frame.filename ] = debugIdMap[ stack ]; + break; + } + } + } + return result; +}; + +// Produce the Sentry `debug_meta` images for the files referenced by this +// exception's stack, so Sentry can match the uploaded source maps by Debug ID +// (independent of the unstable on-device file paths). +const getDebugImages = ( stacktrace ) => { + const debugIdsByFilename = getDebugIdsByFilename(); + const seen = new Set(); + const images = []; + + for ( const frame of stacktrace ) { + const filename = frame.filename; + const debugId = filename && debugIdsByFilename[ filename ]; + if ( debugId && ! seen.has( filename ) ) { + seen.add( filename ); + images.push( { code_file: filename, debug_id: debugId } ); + } + } + return images; +}; + export default ( exception, { context, tags } = {} ) => { + const parsed = parseException( exception ); return { - ...parseException( exception ), + ...parsed, + debug_images: getDebugImages( parsed.stacktrace ), context: { ...context, }, diff --git a/src/utils/exception-parser.test.js b/src/utils/exception-parser.test.js new file mode 100644 index 000000000..3695540dc --- /dev/null +++ b/src/utils/exception-parser.test.js @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { describe, it, expect, afterEach } from 'vitest'; + +/** + * Internal dependencies + */ +import parseException from './exception-parser'; + +describe( 'parseException debug images', () => { + afterEach( () => { + delete window._sentryDebugIds; + } ); + + it( 'emits a debug image for each stack file that has a Debug ID', () => { + // `sentry-cli sourcemaps inject` records `(new Error).stack -> debugId`; + // the stack's top frame identifies the chunk the snippet ran in. + window._sentryDebugIds = { + 'Error\n at https://example.com/assets/editor-abc.js:1:1': + '11111111-1111-1111-1111-111111111111', + 'Error\n at https://example.com/assets/index-def.js:1:1': + '22222222-2222-2222-2222-222222222222', + }; + + const exception = { + name: 'TypeError', + message: 'boom', + stack: [ + 'TypeError: boom', + ' at fn (https://example.com/assets/editor-abc.js:22:247)', + ' at go (https://example.com/assets/index-def.js:3:9)', + ].join( '\n' ), + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( + expect.arrayContaining( [ + { + code_file: 'https://example.com/assets/editor-abc.js', + debug_id: '11111111-1111-1111-1111-111111111111', + }, + { + code_file: 'https://example.com/assets/index-def.js', + debug_id: '22222222-2222-2222-2222-222222222222', + }, + ] ) + ); + expect( debugImages ).toHaveLength( 2 ); + } ); + + it( 'deduplicates files that appear in multiple frames', () => { + window._sentryDebugIds = { + 'Error\n at https://example.com/assets/editor-abc.js:1:1': + '11111111-1111-1111-1111-111111111111', + }; + + const exception = { + name: 'Error', + message: 'boom', + stack: [ + 'Error: boom', + ' at a (https://example.com/assets/editor-abc.js:22:247)', + ' at b (https://example.com/assets/editor-abc.js:30:5)', + ].join( '\n' ), + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( [ + { + code_file: 'https://example.com/assets/editor-abc.js', + debug_id: '11111111-1111-1111-1111-111111111111', + }, + ] ); + } ); + + it( 'returns an empty list when no Debug IDs are present', () => { + const exception = { + name: 'Error', + message: 'boom', + stack: 'Error: boom\n at a (https://example.com/assets/editor-abc.js:1:1)', + }; + + const { debug_images: debugImages } = parseException( exception ); + + expect( debugImages ).toEqual( [] ); + } ); +} ); From 876c0204b23341f1f9b7572a6ef1d11916b4cb64 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 18 Jul 2026 07:48:22 -0400 Subject: [PATCH 6/7] feat: carry source-map Debug IDs through the iOS exception bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `debugImages` property (and a `DebugImage` struct pairing `codeFile` with `debugID`) to `GutenbergJSException`, decoded from the `debug_images` payload the editor now sends. The field is optional — payloads from builds without Debug IDs decode to an empty list — so nothing regresses. The host forwards these to the crash-logging SDK, which attaches them as `debug_meta` on the Sentry event for source-map symbolication. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EditorViewControllerDelegate.swift | 22 +++++++ .../GutenbergJSExceptionTests.swift | 60 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 91041f7b4..5faf740db 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -102,6 +102,9 @@ public struct GutenbergJSException { public let tags: [String: String] public let isHandled: Bool public let handledBy: String + /// Source-map Debug IDs for the files in the stack trace, used to symbolicate + /// the exception in Sentry regardless of the on-device file paths. + public let debugImages: [DebugImage] public struct StacktraceLine { public let filename: String? @@ -120,6 +123,22 @@ public struct GutenbergJSException { } } + /// Pairs a minified file with the Debug ID that identifies its source map. + public struct DebugImage { + public let codeFile: String + public let debugID: String + + init?(from dict: [AnyHashable: Any]) { + guard let codeFile = dict["code_file"] as? String, + let debugID = dict["debug_id"] as? String + else { + return nil + } + self.codeFile = codeFile + self.debugID = debugID + } + } + init?(from dict: [AnyHashable: Any]) { guard let type = dict["type"] as? String, let message = dict["message"] as? String, @@ -139,6 +158,9 @@ public struct GutenbergJSException { self.tags = tags self.isHandled = isHandled self.handledBy = handledBy + // Optional: absent in payloads from builds without Debug IDs. + let rawDebugImages = dict["debug_images"] as? [[AnyHashable: Any]] ?? [] + self.debugImages = rawDebugImages.compactMap { DebugImage(from: $0) } } } diff --git a/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift b/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift new file mode 100644 index 000000000..acd976d61 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/GutenbergJSExceptionTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +@Suite +struct GutenbergJSExceptionTests { + + private func baseException( + debugImages: [[String: Any]]? = nil + ) -> [String: Any] { + var dict: [String: Any] = [ + "type": "TypeError", + "message": "boom", + "stacktrace": [ + ["function": "fn", "filename": "~/assets/editor-abc.js", "lineno": 22, "colno": 247] + ], + "context": [:], + "tags": [:], + "isHandled": false, + "handledBy": "window.error" + ] + if let debugImages { + dict["debug_images"] = debugImages + } + return dict + } + + @Test("Decodes debug images when present") + func decodesDebugImages() throws { + let dict = baseException(debugImages: [ + ["code_file": "~/assets/editor-abc.js", "debug_id": "11111111-1111-1111-1111-111111111111"] + ]) + + let exception = try #require(GutenbergJSException(from: dict)) + + #expect(exception.debugImages.count == 1) + #expect(exception.debugImages.first?.codeFile == "~/assets/editor-abc.js") + #expect(exception.debugImages.first?.debugID == "11111111-1111-1111-1111-111111111111") + } + + @Test("Defaults to no debug images when the field is absent") + func defaultsToEmptyWhenAbsent() throws { + let exception = try #require(GutenbergJSException(from: baseException())) + #expect(exception.debugImages.isEmpty) + } + + @Test("Skips malformed debug images") + func skipsMalformedDebugImages() throws { + let dict = baseException(debugImages: [ + ["code_file": "~/assets/editor-abc.js"], // missing debug_id + ["code_file": "~/assets/index-def.js", "debug_id": "22222222-2222-2222-2222-222222222222"] + ]) + + let exception = try #require(GutenbergJSException(from: dict)) + + #expect(exception.debugImages.count == 1) + #expect(exception.debugImages.first?.debugID == "22222222-2222-2222-2222-222222222222") + } +} From d0092d2181dacd604f2177afa4e64b70a64ec6a7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Sat, 18 Jul 2026 16:42:59 -0400 Subject: [PATCH 7/7] build: include minified JS in the source-map upload bundle Sentry's Debug ID symbolication needs the minified `.js` alongside its `.map`; uploading only maps produced `js_no_source` / `missing_source` and left frames unsymbolicated. Package both file types (they share the same injected Debug ID) so Sentry can apply the map to the minified source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .buildkite/pipeline.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 6fc85cf89..5fbf97471 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -43,7 +43,11 @@ steps: # map-stripping Makefile targets. Stripping here makes `dist.tar.gz` # map-free for every consumer. echo "--- :sentry: Packaging source maps" - ( cd dist && zip -r -q ../GutenbergKitSourceMaps.zip . -i '*.map' ) + # Include both the minified `.js` and their `.map` files: Sentry's + # Debug ID symbolication needs the minified source alongside the map + # (uploading only maps yields `js_no_source`/`missing_source`). Both + # carry the same injected Debug ID. + ( cd dist && zip -r -q ../GutenbergKitSourceMaps.zip . -i '*.js' '*.map' ) buildkite-agent artifact upload GutenbergKitSourceMaps.zip find dist -name '*.map' -delete tar -czf dist.tar.gz dist/