Skip to content

Commit db23972

Browse files
committed
fix(build): enable sourcemap resolution for compiled binaries
Fix four issues preventing sourcemaps from working in Bun-compiled binaries: 1. Events had no debug_meta because the IIFE snippet was placed before ESM imports (invalid ESM). Register debug IDs at module scope via build-time constant instead. 2. Artifact bundle ZIPs lacked the SYSB magic header required by the symbolic crate's SourceBundle parser. Add the 8-byte header in ZipWriter. 3. Bun.build reformats code (3.7k → 40k lines), breaking sourcemap positions. Use sourcemap: "linked" to embed a map in the binary that Bun's runtime uses to auto-resolve Error.stack positions back to esbuild output coordinates. 4. Bun's embedded map produces relative paths in Error.stack (e.g., "dist-bin/bin.js"). The symbolicator's URL candidate generator skips relative paths silently. Normalize to absolute in beforeSend.
1 parent 3782284 commit db23972

File tree

9 files changed

+335
-44
lines changed

9 files changed

+335
-44
lines changed

script/build.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ 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,6 +263,10 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
215263

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

266+
// Save the esbuild map before Bun.build overwrites it (sourcemap: "linked"
267+
// writes Bun's own map to bin.js.map). We restore it after for upload.
268+
const savedEsbuildMap = await Bun.file(SOURCEMAP_FILE).arrayBuffer();
269+
218270
const result = await Bun.build({
219271
entrypoints: [BUNDLE_JS],
220272
compile: {
@@ -226,9 +278,14 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
226278
| "bun-windows-x64",
227279
outfile,
228280
},
229-
// Already minified in Step 1 — skip re-minification to avoid
230-
// double-minifying identifiers and producing different output.
231-
minify: false,
281+
// "linked" embeds a sourcemap in the binary. At runtime, Bun's engine
282+
// auto-resolves Error.stack positions through this embedded map back to
283+
// the esbuild output positions. The esbuild sourcemap (uploaded to
284+
// Sentry) then maps those to original TypeScript sources.
285+
sourcemap: "linked",
286+
// Minify whitespace and syntax but NOT identifiers to avoid Bun's
287+
// identifier renaming collision bug (oven-sh/bun#14585).
288+
minify: { whitespace: true, syntax: true, identifiers: false },
232289
});
233290

234291
if (!result.success) {
@@ -241,6 +298,9 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
241298

242299
console.log(` -> ${outfile}`);
243300

301+
// Restore the esbuild sourcemap (Bun.build overwrote it with its own map).
302+
await Bun.write(SOURCEMAP_FILE, savedEsbuildMap);
303+
244304
// Hole-punch: zero unused ICU data entries so they compress to nearly nothing.
245305
// Always runs so the smoke test exercises the same binary as the release.
246306
const hpStats = processBinary(outfile);
@@ -344,8 +404,10 @@ async function build(): Promise<void> {
344404
process.exit(1);
345405
}
346406

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

350412
console.log("");
351413

@@ -362,6 +424,9 @@ async function build(): Promise<void> {
362424
}
363425
}
364426

427+
// Step 3: Upload the composed sourcemap to Sentry (after compilation)
428+
await uploadSourcemapToSentry();
429+
365430
// Clean up intermediate bundle (only the binaries are artifacts)
366431
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`;
367432

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 = "00000000-0000-4000-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+
}

src/lib/sourcemap/debug-id.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,22 +64,30 @@ export function getDebugIdSnippet(debugId: string): string {
6464
* Inject a Sentry debug ID into a JavaScript file and its companion
6565
* sourcemap.
6666
*
67-
* Performs four mutations:
67+
* By default performs four mutations:
6868
* 1. Prepends the runtime snippet to the JS file (after any hashbang)
6969
* 2. Appends a `//# debugId=<uuid>` comment to the JS file
7070
* 3. Prepends a `;` to the sourcemap `mappings` (offsets by one line)
7171
* 4. Adds `debug_id` and `debugId` fields to the sourcemap JSON
7272
*
73+
* When `options.skipSnippet` is `true`, step 1 is skipped and step 3
74+
* is adjusted (no extra `;` prefix since no snippet line is added).
75+
* This is used by the CLI's own build pipeline where the debug ID is
76+
* registered in source code (`constants.ts`) instead of via the IIFE.
77+
*
7378
* The operation is **idempotent** — files that already contain a
7479
* `//# debugId=` comment are returned unchanged.
7580
*
7681
* @param jsPath - Path to the JavaScript file
7782
* @param mapPath - Path to the companion `.map` file
83+
* @param options - Optional settings
84+
* @param options.skipSnippet - Skip the IIFE runtime snippet (steps 1 & 3)
7885
* @returns The debug ID and whether it was newly injected
7986
*/
8087
export async function injectDebugId(
8188
jsPath: string,
82-
mapPath: string
89+
mapPath: string,
90+
options?: { skipSnippet?: boolean }
8391
): Promise<{ debugId: string; wasInjected: boolean }> {
8492
const [jsContent, mapContent] = await Promise.all([
8593
readFile(jsPath, "utf-8"),
@@ -94,21 +102,29 @@ export async function injectDebugId(
94102

95103
// Generate debug ID from the sourcemap content (deterministic)
96104
const debugId = contentToDebugId(mapContent);
97-
const snippet = getDebugIdSnippet(debugId);
105+
const skipSnippet = options?.skipSnippet ?? false;
98106

99107
// --- Mutate JS file ---
100-
// Preserve hashbang if present, insert snippet after it
101108
let newJs: string;
102-
if (jsContent.startsWith("#!")) {
103-
const newlineIdx = jsContent.indexOf("\n");
104-
// Handle hashbang without trailing newline (entire file is the #! line)
105-
const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1;
106-
const hashbang = jsContent.slice(0, splitAt);
107-
const rest = jsContent.slice(splitAt);
108-
const sep = newlineIdx === -1 ? "\n" : "";
109-
newJs = `${hashbang}${sep}${snippet}\n${rest}`;
109+
if (skipSnippet) {
110+
// Metadata-only mode: just append the debugId comment, no IIFE snippet.
111+
// Used by the CLI's own build where the debug ID is registered in source.
112+
newJs = jsContent;
110113
} else {
111-
newJs = `${snippet}\n${jsContent}`;
114+
// Full mode: prepend the runtime IIFE snippet (for user-facing injection).
115+
const snippet = getDebugIdSnippet(debugId);
116+
// Preserve hashbang if present, insert snippet after it
117+
if (jsContent.startsWith("#!")) {
118+
const newlineIdx = jsContent.indexOf("\n");
119+
// Handle hashbang without trailing newline (entire file is the #! line)
120+
const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1;
121+
const hashbang = jsContent.slice(0, splitAt);
122+
const rest = jsContent.slice(splitAt);
123+
const sep = newlineIdx === -1 ? "\n" : "";
124+
newJs = `${hashbang}${sep}${snippet}\n${rest}`;
125+
} else {
126+
newJs = `${snippet}\n${jsContent}`;
127+
}
112128
}
113129
// Append debug ID comment at the end
114130
newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`;
@@ -120,10 +136,12 @@ export async function injectDebugId(
120136
debug_id?: string;
121137
debugId?: string;
122138
};
123-
// Prepend one `;` to mappings — tells decoders "no mappings for the
124-
// first line" (the injected snippet line). Each `;` in VLQ mappings
125-
// represents a line boundary.
126-
map.mappings = `;${map.mappings}`;
139+
if (!skipSnippet) {
140+
// Prepend one `;` to mappings — tells decoders "no mappings for the
141+
// first line" (the injected snippet line). Each `;` in VLQ mappings
142+
// represents a line boundary.
143+
map.mappings = `;${map.mappings}`;
144+
}
127145
map.debug_id = debugId;
128146
map.debugId = debugId;
129147

0 commit comments

Comments
 (0)