Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/src/fragments/commands/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@


## Examples

### List logs
Expand Down
142 changes: 106 additions & 36 deletions script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@
* bin.js.map (sourcemap, uploaded to Sentry then deleted)
*/

import { mkdirSync } from "node:fs";
import { mkdirSync, renameSync } from "node:fs";
import { promisify } from "node:util";
import { gzip } from "node:zlib";
import { processBinary } from "binpunch";
import { $ } from "bun";
import { build as esbuild } from "esbuild";
import pkg from "../package.json";
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
import { injectDebugId } from "./debug-id.js";
import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js";

const gzipAsync = promisify(gzip);

Expand Down Expand Up @@ -104,13 +104,15 @@ async function bundleJs(): Promise<boolean> {
target: "esnext",
format: "esm",
external: ["bun:*"],
sourcemap: "external",
sourcemap: "linked",
// Minify syntax and whitespace but NOT identifiers. Bun.build
minify: true,
metafile: true,
define: {
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
"process.env.NODE_ENV": JSON.stringify("production"),
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
},
});

Expand Down Expand Up @@ -147,20 +149,57 @@ async function bundleJs(): Promise<boolean> {
* injection always runs (even without auth token) so local builds get
* debug IDs for development/testing.
*/
async function injectAndUploadSourcemap(): Promise<void> {
// Always inject debug IDs (even without auth token) so local builds
// get debug IDs for development/testing purposes.
/** Module-level debug ID set by {@link injectDebugIds} for use in {@link uploadSourcemapToSentry}. */
let currentDebugId: string | undefined;

/**
* Inject debug IDs into the JS and sourcemap. Runs before compilation.
* The upload happens separately after compilation (see {@link uploadSourcemapToSentry}).
*/
async function injectDebugIds(): Promise<void> {
// skipSnippet: true — the IIFE snippet breaks ESM (placed before import
// declarations). The debug ID is instead registered in constants.ts via
// a build-time __SENTRY_DEBUG_ID__ constant.
console.log(" Injecting debug IDs...");
let debugId: string;
try {
({ debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE));
const { debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE, {
skipSnippet: true,
});
currentDebugId = debugId;
console.log(` -> Debug ID: ${debugId}`);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(` Warning: Debug ID injection failed: ${msg}`);
return;
}

// Replace the placeholder UUID with the real debug ID in the JS bundle.
// Both are 36-char UUIDs so sourcemap character positions stay valid.
try {
const jsContent = await Bun.file(BUNDLE_JS).text();
await Bun.write(
BUNDLE_JS,
jsContent.split(PLACEHOLDER_DEBUG_ID).join(currentDebugId)
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(
` Warning: Debug ID placeholder replacement failed: ${msg}`
);
}
}

/**
* Upload the (composed) sourcemap to Sentry. Runs after compilation
* because {@link compileTarget} composes the Bun sourcemap with the
* esbuild sourcemap first.
*/
async function uploadSourcemapToSentry(): Promise<void> {
const debugId = currentDebugId;
if (!debugId) {
return;
}

if (!process.env.SENTRY_AUTH_TOKEN) {
console.log(" No SENTRY_AUTH_TOKEN, skipping sourcemap upload");
return;
Expand All @@ -169,7 +208,13 @@ async function injectAndUploadSourcemap(): Promise<void> {
console.log(` Uploading sourcemap to Sentry (release: ${VERSION})...`);

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

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

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

const result = await Bun.build({
entrypoints: [BUNDLE_JS],
compile: {
target: getBunTarget(target) as
| "bun-darwin-arm64"
| "bun-darwin-x64"
| "bun-linux-x64"
| "bun-linux-arm64"
| "bun-windows-x64",
outfile,
},
// Already minified in Step 1 — skip re-minification to avoid
// double-minifying identifiers and producing different output.
minify: false,
});

if (!result.success) {
console.error(` Failed to compile ${packageName}:`);
for (const log of result.logs) {
console.error(` ${log}`);
// Rename the esbuild map out of the way before Bun.build overwrites it
// (sourcemap: "linked" writes Bun's own map to bin.js.map).
// Restored in the finally block so subsequent targets and the upload
// always find the esbuild map, even if compilation fails.
const esbuildMapBackup = `${SOURCEMAP_FILE}.esbuild`;
renameSync(SOURCEMAP_FILE, esbuildMapBackup);

try {
const result = await Bun.build({
entrypoints: [BUNDLE_JS],
compile: {
target: getBunTarget(target) as
| "bun-darwin-arm64"
| "bun-darwin-x64"
| "bun-linux-x64"
| "bun-linux-arm64"
| "bun-windows-x64",
outfile,
},
// "linked" embeds a sourcemap in the binary. At runtime, Bun's engine
// auto-resolves Error.stack positions through this embedded map back to
// the esbuild output positions. The esbuild sourcemap (uploaded to
// Sentry) then maps those to original TypeScript sources.
sourcemap: "linked",
// Minify whitespace and syntax but NOT identifiers to avoid Bun's
// identifier renaming collision bug (oven-sh/bun#14585).
minify: { whitespace: true, syntax: true, identifiers: false },
});

if (!result.success) {
console.error(` Failed to compile ${packageName}:`);
for (const log of result.logs) {
console.error(` ${log}`);
}
return false;
}
return false;
}

console.log(` -> ${outfile}`);
console.log(` -> ${outfile}`);
} finally {
// Restore the esbuild sourcemap (Bun.build wrote its own map).
renameSync(esbuildMapBackup, SOURCEMAP_FILE);
}

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

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

console.log("");

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

// Step 3: Upload the composed sourcemap to Sentry (after compilation)
await uploadSourcemapToSentry();

// Clean up intermediate bundle (only the binaries are artifacts)
await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`;

Expand Down
17 changes: 15 additions & 2 deletions script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { unlink } from "node:fs/promises";
import { build, type Plugin } from "esbuild";
import pkg from "../package.json";
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
import { injectDebugId } from "./debug-id.js";
import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js";

const VERSION = pkg.version;
const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID ?? "";
Expand Down Expand Up @@ -67,7 +67,9 @@ async function injectDebugIdsForOutputs(
for (const jsPath of jsFiles) {
const mapPath = `${jsPath}.map`;
try {
const { debugId } = await injectDebugId(jsPath, mapPath);
const { debugId } = await injectDebugId(jsPath, mapPath, {
skipSnippet: true,
});
injected.push({ jsPath, mapPath, debugId });
console.log(` Debug ID injected: ${debugId}`);
} catch (err) {
Expand Down Expand Up @@ -151,6 +153,16 @@ const sentrySourcemapPlugin: Plugin = {
return;
}

// Replace the placeholder UUID with the real debug ID in each JS output.
// Both are 36-char UUIDs so sourcemap character positions stay valid.
for (const { jsPath, debugId } of injected) {
const content = await Bun.file(jsPath).text();
await Bun.write(
jsPath,
content.split(PLACEHOLDER_DEBUG_ID).join(debugId)
);
}

if (!process.env.SENTRY_AUTH_TOKEN) {
return;
}
Expand Down Expand Up @@ -194,6 +206,7 @@ const result = await build({
SENTRY_CLI_VERSION: JSON.stringify(VERSION),
SENTRY_CLIENT_ID_BUILD: JSON.stringify(SENTRY_CLIENT_ID),
"process.env.NODE_ENV": JSON.stringify("production"),
__SENTRY_DEBUG_ID__: JSON.stringify(PLACEHOLDER_DEBUG_ID),
// Replace import.meta.url with the injected shim variable for CJS
"import.meta.url": "import_meta_url",
},
Expand Down
10 changes: 10 additions & 0 deletions script/debug-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ export {
getDebugIdSnippet,
injectDebugId,
} from "../src/lib/sourcemap/debug-id.js";

/**
* Placeholder UUID used by esbuild's `define` for `__SENTRY_DEBUG_ID__`.
*
* After esbuild finishes and the real debug ID is computed from the
* sourcemap content hash, this placeholder is replaced in the JS output
* via `String.replaceAll`. The placeholder is exactly 36 characters
* (standard UUID length) so character positions in the sourcemap stay valid.
*/
export const PLACEHOLDER_DEBUG_ID = "deb00000-de60-4d00-a000-000000000000";
39 changes: 39 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { getEnv } from "./env.js";
/** Build-time constant injected by esbuild/bun */
declare const SENTRY_CLI_VERSION: string | undefined;

/**
* Build-time debug ID for sourcemap resolution, injected by esbuild.
*
* During the build, esbuild's `define` replaces this identifier with a
* placeholder UUID string literal. After esbuild finishes, the build
* script replaces the placeholder with the real debug ID (derived from
* the sourcemap content hash). The same-length swap keeps sourcemap
* character positions valid.
*/
declare const __SENTRY_DEBUG_ID__: string | undefined;

/** Default Sentry SaaS hostname */
export const DEFAULT_SENTRY_HOST = "sentry.io";

Expand Down Expand Up @@ -106,3 +117,31 @@ export function getUserAgent(): string {
*/
export const SENTRY_CLI_DSN =
"https://1188a86f3f8168f089450587b00bca66@o1.ingest.us.sentry.io/4510776311808000";

/**
* Register the build-time debug ID with the Sentry SDK's native discovery.
*
* The SDK reads `globalThis._sentryDebugIds` (a map of Error.stack → debugId)
* during event processing to populate `debug_meta.images`, which the server
* uses to match uploaded sourcemaps.
*
* Previously this was done by a runtime IIFE snippet prepended to the bundle
* output by `injectDebugId()`. That broke ESM because the snippet appeared
* before `import` declarations. Placing the same logic here — inside the
* module, after all imports — is valid ESM and feeds the SDK's existing
* mechanism directly.
*/
if (typeof __SENTRY_DEBUG_ID__ !== "undefined") {
try {
// biome-ignore lint/suspicious/useErrorMessage: stack trace capture only
const stack = new Error().stack;
if (stack) {
// biome-ignore lint/suspicious/noExplicitAny: SDK reads this untyped global
const g = globalThis as any;
g._sentryDebugIds = g._sentryDebugIds || {};
g._sentryDebugIds[stack] = __SENTRY_DEBUG_ID__;
}
} catch (_) {
// Non-critical — sourcemap resolution degrades gracefully
}
}
Loading
Loading