Skip to content

Commit 7e6689a

Browse files
fix(build): enable sourcemap resolution for compiled binaries (#701)
## Summary Fixes sourcemap resolution for the Sentry CLI's Bun-compiled binaries. Error events now show original TypeScript file paths, line numbers, and function names instead of minified code. **Before**: `at ySt (/$bunfs/root/bin.js:40106:19)` **After**: `at resolveProjectSearchFallback (../src/commands/issue/utils.ts:165:15)` ## What was wrong Four distinct issues prevented sourcemaps from working: ### 1. No `debug_meta` in events The debug ID IIFE snippet was injected before ESM `import` declarations, creating invalid ESM that Bun's compiler dropped. Events had no `debug_meta` — the server couldn't even attempt resolution. **Fix**: Register debug IDs at module scope in `constants.ts` via a build-time `__SENTRY_DEBUG_ID__` constant (esbuild `define` with placeholder UUID, replaced after sourcemap hash computation). ### 2. Artifact bundle ZIP format Our `ZipWriter` produced plain ZIPs. The symbolicator uses the `symbolic` crate which requires an 8-byte `SYSB` magic header before the ZIP data. **Fix**: `ZipWriter.create()` writes the SYSB header. ### 3. Line/column position mismatch `Bun.build({ compile: true })` reformats code (3.7k lines → 40k lines), making esbuild's sourcemap positions invalid. **Fix**: `sourcemap: "linked"` in `Bun.build` embeds a map in the binary. Bun's runtime auto-resolves `Error.stack` positions back to esbuild output coordinates. The esbuild map is saved before `Bun.build` (which overwrites `bin.js.map`) and restored after. ### 4. Symbolicator silently skips relative paths Bun's embedded map resolves to relative paths (`dist-bin/bin.js`). The symbolicator's URL candidate generator produces `~dist-bin/bin.js` (missing `/` after `~`) for relative paths, which never matches uploaded artifacts. **Fix**: `beforeSend` normalizes relative frame paths and `debug_meta.images[].code_file` to absolute by prepending `/`. ## Verification Built locally, triggered an error, verified in Sentry: - Original function name: `resolveProjectSearchFallback` - Original source file: `../src/commands/issue/utils.ts:165:15` - No processing errors Event: https://sentry.sentry.io/issues/7398675564/events/0192a9a1d94f43519ee697e566f08b92/ --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent abdc5e4 commit 7e6689a

File tree

9 files changed

+361
-65
lines changed

9 files changed

+361
-65
lines changed

script/build.ts

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@
3030
* bin.js.map (sourcemap, uploaded to Sentry then deleted)
3131
*/
3232

33-
import { mkdirSync } from "node:fs";
33+
import { mkdirSync, renameSync } from "node:fs";
3434
import { promisify } from "node:util";
3535
import { gzip } from "node:zlib";
3636
import { processBinary } from "binpunch";
3737
import { $ } from "bun";
3838
import { build as esbuild } from "esbuild";
3939
import pkg from "../package.json";
4040
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
41-
import { injectDebugId } from "./debug-id.js";
41+
import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js";
4242

4343
const gzipAsync = promisify(gzip);
4444

@@ -104,13 +104,15 @@ async function bundleJs(): Promise<boolean> {
104104
target: "esnext",
105105
format: "esm",
106106
external: ["bun:*"],
107-
sourcemap: "external",
107+
sourcemap: "linked",
108+
// Minify syntax and whitespace but NOT identifiers. Bun.build
108109
minify: true,
109110
metafile: true,
110111
define: {
111112
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
112113
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
113114
"process.env.NODE_ENV": JSON.stringify("production"),
115+
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
114116
},
115117
});
116118

@@ -147,20 +149,57 @@ async function bundleJs(): Promise<boolean> {
147149
* injection always runs (even without auth token) so local builds get
148150
* debug IDs for development/testing.
149151
*/
150-
async function injectAndUploadSourcemap(): Promise<void> {
151-
// Always inject debug IDs (even without auth token) so local builds
152-
// get debug IDs for development/testing purposes.
152+
/** Module-level debug ID set by {@link injectDebugIds} for use in {@link uploadSourcemapToSentry}. */
153+
let currentDebugId: string | undefined;
154+
155+
/**
156+
* Inject debug IDs into the JS and sourcemap. Runs before compilation.
157+
* The upload happens separately after compilation (see {@link uploadSourcemapToSentry}).
158+
*/
159+
async function injectDebugIds(): Promise<void> {
160+
// skipSnippet: true — the IIFE snippet breaks ESM (placed before import
161+
// declarations). The debug ID is instead registered in constants.ts via
162+
// a build-time __SENTRY_DEBUG_ID__ constant.
153163
console.log(" Injecting debug IDs...");
154-
let debugId: string;
155164
try {
156-
({ debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE));
165+
const { debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE, {
166+
skipSnippet: true,
167+
});
168+
currentDebugId = debugId;
157169
console.log(` -> Debug ID: ${debugId}`);
158170
} catch (error) {
159171
const msg = error instanceof Error ? error.message : String(error);
160172
console.warn(` Warning: Debug ID injection failed: ${msg}`);
161173
return;
162174
}
163175

176+
// Replace the placeholder UUID with the real debug ID in the JS bundle.
177+
// Both are 36-char UUIDs so sourcemap character positions stay valid.
178+
try {
179+
const jsContent = await Bun.file(BUNDLE_JS).text();
180+
await Bun.write(
181+
BUNDLE_JS,
182+
jsContent.split(PLACEHOLDER_DEBUG_ID).join(currentDebugId)
183+
);
184+
} catch (error) {
185+
const msg = error instanceof Error ? error.message : String(error);
186+
console.warn(
187+
` Warning: Debug ID placeholder replacement failed: ${msg}`
188+
);
189+
}
190+
}
191+
192+
/**
193+
* Upload the (composed) sourcemap to Sentry. Runs after compilation
194+
* because {@link compileTarget} composes the Bun sourcemap with the
195+
* esbuild sourcemap first.
196+
*/
197+
async function uploadSourcemapToSentry(): Promise<void> {
198+
const debugId = currentDebugId;
199+
if (!debugId) {
200+
return;
201+
}
202+
164203
if (!process.env.SENTRY_AUTH_TOKEN) {
165204
console.log(" No SENTRY_AUTH_TOKEN, skipping sourcemap upload");
166205
return;
@@ -169,7 +208,13 @@ async function injectAndUploadSourcemap(): Promise<void> {
169208
console.log(` Uploading sourcemap to Sentry (release: ${VERSION})...`);
170209

171210
try {
172-
const urlPrefix = "~/$bunfs/root/";
211+
// With sourcemap: "linked", Bun's runtime auto-resolves Error.stack
212+
// paths via the embedded map, producing relative paths like
213+
// "dist-bin/bin.js". The beforeSend hook normalizes these to absolute
214+
// ("/dist-bin/bin.js") so the symbolicator's candidate URL generator
215+
// produces "~/dist-bin/bin.js" — matching our upload URL.
216+
const dir = BUNDLE_JS.slice(0, BUNDLE_JS.lastIndexOf("/") + 1);
217+
const urlPrefix = `~/${dir}`;
173218
const jsBasename = BUNDLE_JS.split("/").pop() ?? "bin.js";
174219
const mapBasename = SOURCEMAP_FILE.split("/").pop() ?? "bin.js.map";
175220

@@ -204,8 +249,11 @@ async function injectAndUploadSourcemap(): Promise<void> {
204249
/**
205250
* Step 2: Compile the pre-bundled JS into a native binary for a target.
206251
*
207-
* Uses the JS file produced by {@link bundleJs} — no sourcemap is embedded
208-
* in the binary (it's uploaded to Sentry separately).
252+
* Uses the JS file produced by {@link bundleJs}. The esbuild sourcemap
253+
* (JS → original TS) is uploaded to Sentry as-is — no composition needed
254+
* because `sourcemap: "linked"` causes Bun to embed a sourcemap in the
255+
* binary that its runtime uses to auto-resolve `Error.stack` positions
256+
* back to the esbuild output's coordinate space.
209257
*/
210258
async function compileTarget(target: BuildTarget): Promise<boolean> {
211259
const packageName = getPackageName(target);
@@ -215,31 +263,48 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
215263

216264
console.log(` Step 2: Compiling ${packageName}...`);
217265

218-
const result = await Bun.build({
219-
entrypoints: [BUNDLE_JS],
220-
compile: {
221-
target: getBunTarget(target) as
222-
| "bun-darwin-arm64"
223-
| "bun-darwin-x64"
224-
| "bun-linux-x64"
225-
| "bun-linux-arm64"
226-
| "bun-windows-x64",
227-
outfile,
228-
},
229-
// Already minified in Step 1 — skip re-minification to avoid
230-
// double-minifying identifiers and producing different output.
231-
minify: false,
232-
});
233-
234-
if (!result.success) {
235-
console.error(` Failed to compile ${packageName}:`);
236-
for (const log of result.logs) {
237-
console.error(` ${log}`);
266+
// Rename the esbuild map out of the way before Bun.build overwrites it
267+
// (sourcemap: "linked" writes Bun's own map to bin.js.map).
268+
// Restored in the finally block so subsequent targets and the upload
269+
// always find the esbuild map, even if compilation fails.
270+
const esbuildMapBackup = `${SOURCEMAP_FILE}.esbuild`;
271+
renameSync(SOURCEMAP_FILE, esbuildMapBackup);
272+
273+
try {
274+
const result = await Bun.build({
275+
entrypoints: [BUNDLE_JS],
276+
compile: {
277+
target: getBunTarget(target) as
278+
| "bun-darwin-arm64"
279+
| "bun-darwin-x64"
280+
| "bun-linux-x64"
281+
| "bun-linux-arm64"
282+
| "bun-windows-x64",
283+
outfile,
284+
},
285+
// "linked" embeds a sourcemap in the binary. At runtime, Bun's engine
286+
// auto-resolves Error.stack positions through this embedded map back to
287+
// the esbuild output positions. The esbuild sourcemap (uploaded to
288+
// Sentry) then maps those to original TypeScript sources.
289+
sourcemap: "linked",
290+
// Minify whitespace and syntax but NOT identifiers to avoid Bun's
291+
// identifier renaming collision bug (oven-sh/bun#14585).
292+
minify: { whitespace: true, syntax: true, identifiers: false },
293+
});
294+
295+
if (!result.success) {
296+
console.error(` Failed to compile ${packageName}:`);
297+
for (const log of result.logs) {
298+
console.error(` ${log}`);
299+
}
300+
return false;
238301
}
239-
return false;
240-
}
241302

242-
console.log(` -> ${outfile}`);
303+
console.log(` -> ${outfile}`);
304+
} finally {
305+
// Restore the esbuild sourcemap (Bun.build wrote its own map).
306+
renameSync(esbuildMapBackup, SOURCEMAP_FILE);
307+
}
243308

244309
// Hole-punch: zero unused ICU data entries so they compress to nearly nothing.
245310
// Always runs so the smoke test exercises the same binary as the release.
@@ -344,8 +409,10 @@ async function build(): Promise<void> {
344409
process.exit(1);
345410
}
346411

347-
// Inject debug IDs and upload sourcemap to Sentry before compiling (non-fatal on failure)
348-
await injectAndUploadSourcemap();
412+
// Inject debug IDs into the JS and sourcemap (non-fatal on failure).
413+
// Upload happens AFTER compilation because Bun.build (with sourcemap: "linked")
414+
// overwrites bin.js.map. We restore it from the saved copy before uploading.
415+
await injectDebugIds();
349416

350417
console.log("");
351418

@@ -362,6 +429,9 @@ async function build(): Promise<void> {
362429
}
363430
}
364431

432+
// Step 3: Upload the composed sourcemap to Sentry (after compilation)
433+
await uploadSourcemapToSentry();
434+
365435
// Clean up intermediate bundle (only the binaries are artifacts)
366436
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`;
367437

script/bundle.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { unlink } from "node:fs/promises";
33
import { build, type Plugin } from "esbuild";
44
import pkg from "../package.json";
55
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
6-
import { injectDebugId } from "./debug-id.js";
6+
import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js";
77

88
const VERSION = pkg.version;
99
const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID ?? "";
@@ -67,7 +67,9 @@ async function injectDebugIdsForOutputs(
6767
for (const jsPath of jsFiles) {
6868
const mapPath = `${jsPath}.map`;
6969
try {
70-
const { debugId } = await injectDebugId(jsPath, mapPath);
70+
const { debugId } = await injectDebugId(jsPath, mapPath, {
71+
skipSnippet: true,
72+
});
7173
injected.push({ jsPath, mapPath, debugId });
7274
console.log(` Debug ID injected: ${debugId}`);
7375
} catch (err) {
@@ -151,6 +153,16 @@ const sentrySourcemapPlugin: Plugin = {
151153
return;
152154
}
153155

156+
// Replace the placeholder UUID with the real debug ID in each JS output.
157+
// Both are 36-char UUIDs so sourcemap character positions stay valid.
158+
for (const { jsPath, debugId } of injected) {
159+
const content = await Bun.file(jsPath).text();
160+
await Bun.write(
161+
jsPath,
162+
content.split(PLACEHOLDER_DEBUG_ID).join(debugId)
163+
);
164+
}
165+
154166
if (!process.env.SENTRY_AUTH_TOKEN) {
155167
return;
156168
}
@@ -194,6 +206,7 @@ const result = await build({
194206
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
195207
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
196208
"process.env.NODE_ENV": JSON.stringify("production"),
209+
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
197210
// Replace import.meta.url with the injected shim variable for CJS
198211
"import.meta.url": "import_meta_url",
199212
},

script/debug-id.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,13 @@ export {
1010
getDebugIdSnippet,
1111
injectDebugId,
1212
} from "../src/lib/sourcemap/debug-id.js";
13+
14+
/**
15+
* Placeholder UUID used by esbuild's `define` for `__SENTRY_DEBUG_ID__`.
16+
*
17+
* After esbuild finishes and the real debug ID is computed from the
18+
* sourcemap content hash, this placeholder is replaced in the JS output
19+
* via `String.replaceAll`. The placeholder is exactly 36 characters
20+
* (standard UUID length) so character positions in the sourcemap stay valid.
21+
*/
22+
export const PLACEHOLDER_DEBUG_ID = "deb00000-de60-4d00-a000-000000000000";

src/lib/constants.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ import { getEnv } from "./env.js";
77
/** Build-time constant injected by esbuild/bun */
88
declare const SENTRY_CLI_VERSION: string | undefined;
99

10+
/**
11+
* Build-time debug ID for sourcemap resolution, injected by esbuild.
12+
*
13+
* During the build, esbuild's `define` replaces this identifier with a
14+
* placeholder UUID string literal. After esbuild finishes, the build
15+
* script replaces the placeholder with the real debug ID (derived from
16+
* the sourcemap content hash). The same-length swap keeps sourcemap
17+
* character positions valid.
18+
*/
19+
declare const __SENTRY_DEBUG_ID__: string | undefined;
20+
1021
/** Default Sentry SaaS hostname */
1122
export const DEFAULT_SENTRY_HOST = "sentry.io";
1223

@@ -106,3 +117,31 @@ export function getUserAgent(): string {
106117
*/
107118
export const SENTRY_CLI_DSN =
108119
"https://1188a86f3f8168f089450587b00bca66@o1.ingest.us.sentry.io/4510776311808000";
120+
121+
/**
122+
* Register the build-time debug ID with the Sentry SDK's native discovery.
123+
*
124+
* The SDK reads `globalThis._sentryDebugIds` (a map of Error.stack → debugId)
125+
* during event processing to populate `debug_meta.images`, which the server
126+
* uses to match uploaded sourcemaps.
127+
*
128+
* Previously this was done by a runtime IIFE snippet prepended to the bundle
129+
* output by `injectDebugId()`. That broke ESM because the snippet appeared
130+
* before `import` declarations. Placing the same logic here — inside the
131+
* module, after all imports — is valid ESM and feeds the SDK's existing
132+
* mechanism directly.
133+
*/
134+
if (typeof __SENTRY_DEBUG_ID__ !== "undefined") {
135+
try {
136+
// biome-ignore lint/suspicious/useErrorMessage: stack trace capture only
137+
const stack = new Error().stack;
138+
if (stack) {
139+
// biome-ignore lint/suspicious/noExplicitAny: SDK reads this untyped global
140+
const g = globalThis as any;
141+
g._sentryDebugIds = g._sentryDebugIds || {};
142+
g._sentryDebugIds[stack] = __SENTRY_DEBUG_ID__;
143+
}
144+
} catch (_) {
145+
// Non-critical — sourcemap resolution degrades gracefully
146+
}
147+
}

0 commit comments

Comments
 (0)