Skip to content

Commit 838ccaa

Browse files
authored
feat: native debug ID injection and sourcemap upload (#543)
## Summary Replace `npx @sentry/cli sourcemaps inject` and `@sentry/esbuild-plugin` with native TypeScript implementations. **Zero new dependencies** — only uses `node:crypto`, `node:fs/promises`, `node:zlib`, and existing `p-limit`. ## What changed ### Debug ID Injection (`script/debug-id.ts`) - `contentToDebugId()`: SHA-256 → UUID v4 (byte-for-byte compatible with `@sentry/bundler-plugin-core`'s `stringToUUID`, verified against the actual package) - `getDebugIdSnippet()`: Runtime IIFE that registers debug IDs in `globalThis._sentryDebugIds` - `injectDebugId()`: Injects snippet + comment into JS, adjusts sourcemap mappings, adds `debugId`/`debug_id` fields - Idempotent, hashbang-safe, always runs (even without `SENTRY_AUTH_TOKEN`) ### Streaming ZIP Builder (`src/lib/sourcemap/zip.ts`) - Writes entries to disk via `node:fs/promises` `FileHandle` — only one compressed entry in memory at a time - Uses raw DEFLATE (`node:zlib`) for compression, STORE for empty files - Produces valid ZIP archives extractable by standard tools ### Sourcemap Upload API (`src/lib/api/sourcemaps.ts`) - Implements Sentry's chunk-upload + assemble protocol natively - Parallel chunk uploads via `p-limit` with server-configured concurrency - Gzip compression for chunks (server only supports gzip — verified on US and DE regions) - Streaming: reads ZIP back in 8MB chunks for upload, never holds full bundle in memory ### Build Pipeline - `script/build.ts`: Uses native injection + upload (removed `execSync('npx @sentry/cli ...')`) - `script/bundle.ts`: Custom `sentrySourcemapPlugin` esbuild plugin replaces `@sentry/esbuild-plugin` ### Dependency Removal - `@sentry/esbuild-plugin` removed from devDependencies - Its transitive `@sentry/cli` (Rust binary) dependency is eliminated ## Tests - 13 tests for debug-id (5 property-based, 8 unit) - 10 tests for ZipWriter (2 property-based, 5 unit, 3 binary format) ## Follow-up (PR 3) User-facing CLI commands (`sentry sourcemap inject/upload`) with worker-based parallel processing — tracked separately.
1 parent b4cc6fb commit 838ccaa

File tree

10 files changed

+1464
-288
lines changed

10 files changed

+1464
-288
lines changed

AGENTS.md

Lines changed: 41 additions & 54 deletions
Large diffs are not rendered by default.

bun.lock

Lines changed: 1 addition & 178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"@clack/prompts": "^0.11.0",
1212
"@mastra/client-js": "^1.4.0",
1313
"@sentry/api": "^0.54.0",
14-
"@sentry/esbuild-plugin": "^2.23.0",
1514
"@sentry/node-core": "10.44.0",
1615
"@stricli/auto-complete": "^1.2.4",
1716
"@stricli/core": "^1.2.4",

script/build.ts

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

33-
import { execSync } from "node:child_process";
3433
import { promisify } from "node:util";
3534
import { gzip } from "node:zlib";
3635
import { processBinary } from "binpunch";
3736
import { $ } from "bun";
3837
import pkg from "../package.json";
38+
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
39+
import { injectDebugId } from "./debug-id.js";
3940

4041
const gzipAsync = promisify(gzip);
4142

@@ -117,33 +118,61 @@ async function bundleJs(): Promise<boolean> {
117118
}
118119

119120
/**
120-
* Upload the sourcemap to Sentry for server-side stack trace resolution.
121+
* Inject debug IDs and upload sourcemap to Sentry.
121122
*
122-
* Uses @sentry/cli's `sourcemaps upload` command. The sourcemap is associated
123-
* with the release version so Sentry matches it against incoming error events.
123+
* Both injection and upload are done natively — no external binary needed.
124+
* Uses the chunk-upload + assemble protocol for reliable artifact delivery.
124125
*
125-
* Requires SENTRY_AUTH_TOKEN environment variable. Skips gracefully when
126-
* not available (local builds, PR checks).
126+
* Requires SENTRY_AUTH_TOKEN environment variable for upload. Debug ID
127+
* injection always runs (even without auth token) so local builds get
128+
* debug IDs for development/testing.
127129
*/
128-
function uploadSourcemap(): void {
130+
async function injectAndUploadSourcemap(): Promise<void> {
131+
// Always inject debug IDs (even without auth token) so local builds
132+
// get debug IDs for development/testing purposes.
133+
console.log(" Injecting debug IDs...");
134+
let debugId: string;
135+
try {
136+
({ debugId } = await injectDebugId(BUNDLE_JS, SOURCEMAP_FILE));
137+
console.log(` -> Debug ID: ${debugId}`);
138+
} catch (error) {
139+
const msg = error instanceof Error ? error.message : String(error);
140+
console.warn(` Warning: Debug ID injection failed: ${msg}`);
141+
return;
142+
}
143+
129144
if (!process.env.SENTRY_AUTH_TOKEN) {
130145
console.log(" No SENTRY_AUTH_TOKEN, skipping sourcemap upload");
131146
return;
132147
}
133148

134149
console.log(` Uploading sourcemap to Sentry (release: ${VERSION})...`);
135150

136-
// Single quotes prevent $bunfs shell expansion on POSIX (CI is always Linux).
137151
try {
138-
// Inject debug IDs into JS + map, then upload with /$bunfs/root/ prefix
139-
// to match Bun's compiled binary stack trace paths.
140-
execSync("npx @sentry/cli sourcemaps inject dist-bin/", {
141-
stdio: ["pipe", "pipe", "pipe"],
152+
const urlPrefix = "~/$bunfs/root/";
153+
const jsBasename = BUNDLE_JS.split("/").pop() ?? "bin.js";
154+
const mapBasename = SOURCEMAP_FILE.split("/").pop() ?? "bin.js.map";
155+
156+
await uploadSourcemaps({
157+
org: "sentry",
158+
project: "cli",
159+
release: VERSION,
160+
files: [
161+
{
162+
path: BUNDLE_JS,
163+
debugId,
164+
type: "minified_source",
165+
url: `${urlPrefix}${jsBasename}`,
166+
sourcemapFilename: mapBasename,
167+
},
168+
{
169+
path: SOURCEMAP_FILE,
170+
debugId,
171+
type: "source_map",
172+
url: `${urlPrefix}${mapBasename}`,
173+
},
174+
],
142175
});
143-
execSync(
144-
`npx @sentry/cli sourcemaps upload --org sentry --project cli --release ${VERSION} --url-prefix '/$bunfs/root/' ${BUNDLE_JS} ${SOURCEMAP_FILE}`,
145-
{ stdio: ["pipe", "pipe", "pipe"] }
146-
);
147176
console.log(" -> Sourcemap uploaded to Sentry");
148177
} catch (error) {
149178
// Non-fatal: don't fail the build if upload fails
@@ -294,8 +323,8 @@ async function build(): Promise<void> {
294323
process.exit(1);
295324
}
296325

297-
// Upload sourcemap to Sentry before compiling (non-fatal on failure)
298-
await uploadSourcemap();
326+
// Inject debug IDs and upload sourcemap to Sentry before compiling (non-fatal on failure)
327+
await injectAndUploadSourcemap();
299328

300329
console.log("");
301330

script/bundle.ts

Lines changed: 129 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,9 @@
11
#!/usr/bin/env bun
2-
/**
3-
* Bundle script for npm package
4-
*
5-
* Creates a single-file Node.js bundle using esbuild.
6-
* Injects Bun polyfills for Node.js compatibility.
7-
* Uploads source maps to Sentry when SENTRY_AUTH_TOKEN is available.
8-
*
9-
* Usage:
10-
* bun run script/bundle.ts
11-
*
12-
* Output:
13-
* dist/bin.cjs - Minified, single-file bundle for npm
14-
*/
15-
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";
2+
import { unlink } from "node:fs/promises";
163
import { build, type Plugin } from "esbuild";
174
import pkg from "../package.json";
5+
import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js";
6+
import { injectDebugId } from "./debug-id.js";
187

198
const VERSION = pkg.version;
209
const SENTRY_CLIENT_ID = process.env.SENTRY_CLIENT_ID ?? "";
@@ -33,17 +22,15 @@ if (!SENTRY_CLIENT_ID) {
3322
const BUN_SQLITE_FILTER = /^bun:sqlite$/;
3423
const ANY_FILTER = /.*/;
3524

36-
// Plugin to replace bun:sqlite with our node:sqlite polyfill
25+
/** Plugin to replace bun:sqlite with our node:sqlite polyfill. */
3726
const bunSqlitePlugin: Plugin = {
3827
name: "bun-sqlite-polyfill",
3928
setup(pluginBuild) {
40-
// Intercept imports of "bun:sqlite" and redirect to our polyfill
4129
pluginBuild.onResolve({ filter: BUN_SQLITE_FILTER }, () => ({
4230
path: "bun:sqlite",
4331
namespace: "bun-sqlite-polyfill",
4432
}));
4533

46-
// Provide the polyfill content
4734
pluginBuild.onLoad(
4835
{ filter: ANY_FILTER, namespace: "bun-sqlite-polyfill" },
4936
() => ({
@@ -59,30 +46,135 @@ const bunSqlitePlugin: Plugin = {
5946
},
6047
};
6148

62-
// Configure Sentry plugin for source map uploads (production builds only)
63-
const plugins: Plugin[] = [bunSqlitePlugin];
49+
type InjectedFile = { jsPath: string; mapPath: string; debugId: string };
6450

65-
if (process.env.SENTRY_AUTH_TOKEN) {
66-
console.log(" Sentry auth token found, source maps will be uploaded");
67-
plugins.push(
68-
sentryEsbuildPlugin({
51+
/** Delete .map files after a successful upload — they shouldn't ship to users. */
52+
async function deleteMapFiles(injected: InjectedFile[]): Promise<void> {
53+
for (const { mapPath } of injected) {
54+
try {
55+
await unlink(mapPath);
56+
} catch {
57+
// Ignore — file might already be gone
58+
}
59+
}
60+
}
61+
62+
/** Inject debug IDs into JS outputs and their companion sourcemaps. */
63+
async function injectDebugIdsForOutputs(
64+
jsFiles: string[]
65+
): Promise<InjectedFile[]> {
66+
const injected: InjectedFile[] = [];
67+
for (const jsPath of jsFiles) {
68+
const mapPath = `${jsPath}.map`;
69+
try {
70+
const { debugId } = await injectDebugId(jsPath, mapPath);
71+
injected.push({ jsPath, mapPath, debugId });
72+
console.log(` Debug ID injected: ${debugId}`);
73+
} catch (err) {
74+
const msg = err instanceof Error ? err.message : String(err);
75+
console.warn(
76+
` Warning: Debug ID injection failed for ${jsPath}: ${msg}`
77+
);
78+
}
79+
}
80+
return injected;
81+
}
82+
83+
/**
84+
* Upload injected sourcemaps to Sentry via the chunk-upload protocol.
85+
*
86+
* @returns `true` if upload succeeded, `false` if it failed (non-fatal).
87+
*/
88+
async function uploadInjectedSourcemaps(
89+
injected: InjectedFile[]
90+
): Promise<boolean> {
91+
try {
92+
console.log(" Uploading sourcemaps to Sentry...");
93+
await uploadSourcemaps({
6994
org: "sentry",
7095
project: "cli",
71-
authToken: process.env.SENTRY_AUTH_TOKEN,
72-
release: {
73-
name: VERSION,
74-
},
75-
sourcemaps: {
76-
filesToDeleteAfterUpload: ["dist/**/*.map"],
77-
},
78-
// Don't fail the build if source map upload fails
79-
errorHandler: (err) => {
80-
console.warn(" Warning: Source map upload failed:", err.message);
81-
},
82-
})
83-
);
96+
release: VERSION,
97+
files: injected.flatMap(({ jsPath, mapPath, debugId }) => {
98+
const jsName = jsPath.split("/").pop() ?? "bin.cjs";
99+
const mapName = mapPath.split("/").pop() ?? "bin.cjs.map";
100+
return [
101+
{
102+
path: jsPath,
103+
debugId,
104+
type: "minified_source" as const,
105+
url: `~/${jsName}`,
106+
sourcemapFilename: mapName,
107+
},
108+
{
109+
path: mapPath,
110+
debugId,
111+
type: "source_map" as const,
112+
url: `~/${mapName}`,
113+
},
114+
];
115+
}),
116+
});
117+
console.log(" Sourcemaps uploaded to Sentry");
118+
return true;
119+
} catch (err) {
120+
const msg = err instanceof Error ? err.message : String(err);
121+
console.warn(` Warning: Sourcemap upload failed: ${msg}`);
122+
return false;
123+
}
124+
}
125+
126+
/**
127+
* esbuild plugin that injects debug IDs and uploads sourcemaps to Sentry.
128+
*
129+
* Runs after esbuild finishes bundling (onEnd hook):
130+
* 1. Injects debug IDs into each JS output + its companion .map
131+
* 2. Uploads all artifacts to Sentry via the chunk-upload protocol
132+
* 3. Deletes .map files after upload (they shouldn't ship to users)
133+
*
134+
* Replaces `@sentry/esbuild-plugin` with zero external dependencies.
135+
*/
136+
const sentrySourcemapPlugin: Plugin = {
137+
name: "sentry-sourcemap",
138+
setup(pluginBuild) {
139+
pluginBuild.onEnd(async (buildResult) => {
140+
const outputs = Object.keys(buildResult.metafile?.outputs ?? {});
141+
const jsFiles = outputs.filter(
142+
(p) => p.endsWith(".cjs") || (p.endsWith(".js") && !p.endsWith(".map"))
143+
);
144+
145+
if (jsFiles.length === 0) {
146+
return;
147+
}
148+
149+
const injected = await injectDebugIdsForOutputs(jsFiles);
150+
if (injected.length === 0) {
151+
return;
152+
}
153+
154+
if (!process.env.SENTRY_AUTH_TOKEN) {
155+
return;
156+
}
157+
158+
const uploaded = await uploadInjectedSourcemaps(injected);
159+
160+
// Only delete .map files after a successful upload — preserving
161+
// them on failure allows retrying without a full rebuild.
162+
if (uploaded) {
163+
await deleteMapFiles(injected);
164+
}
165+
});
166+
},
167+
};
168+
169+
// Always inject debug IDs (even without auth token); upload is gated inside the plugin
170+
const plugins: Plugin[] = [bunSqlitePlugin, sentrySourcemapPlugin];
171+
172+
if (process.env.SENTRY_AUTH_TOKEN) {
173+
console.log(" Sentry auth token found, source maps will be uploaded");
84174
} else {
85-
console.log(" No SENTRY_AUTH_TOKEN, skipping source map upload");
175+
console.log(
176+
" No SENTRY_AUTH_TOKEN, debug IDs will be injected but source maps will not be uploaded"
177+
);
86178
}
87179

88180
const result = await build({

0 commit comments

Comments
 (0)