Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@
// api-client.ts is a barrel that re-exports from src/lib/api/ domain modules
// to preserve the existing import path for all consumers
// markdown.ts re-exports isPlainOutput from plain-detect.ts for backward compat
// script/debug-id.ts re-exports from src/lib/sourcemap/debug-id.ts for build scripts
"includes": [
"src/lib/db/index.ts",
"src/lib/api-client.ts",
"src/lib/formatters/markdown.ts"
"src/lib/formatters/markdown.ts",
"script/debug-id.ts"
],
"linter": {
"rules": {
Expand Down
1 change: 1 addition & 0 deletions docs/public/.well-known/skills/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"references/organizations.md",
"references/projects.md",
"references/setup.md",
"references/sourcemap.md",
"references/teams.md",
"references/traces.md",
"references/trials.md"
Expand Down
9 changes: 9 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,15 @@ View Sentry logs

→ Full flags and examples: `references/logs.md`

### Sourcemap

Manage sourcemaps

- `sentry sourcemap inject <directory>` — Inject debug IDs into JavaScript files and sourcemaps
- `sentry sourcemap upload <directory>` — Upload sourcemaps to Sentry

→ Full flags and examples: `references/sourcemap.md`

### Span

List and view spans in projects or traces
Expand Down
30 changes: 30 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: sentry-cli-sourcemap
version: 0.21.0-dev.0
description: Sentry CLI sourcemap commands
requires:
bins: ["sentry"]
auth: true
---

# sourcemap Commands

Manage sourcemaps

### `sentry sourcemap inject <directory>`

Inject debug IDs into JavaScript files and sourcemaps

**Flags:**
- `--ext <value> - Comma-separated file extensions to process (default: .js,.cjs,.mjs)`
- `--dry-run - Show what would be modified without writing`

### `sentry sourcemap upload <directory>`

Upload sourcemaps to Sentry

**Flags:**
- `--release <value> - Release version to associate with the upload`
- `--url-prefix <value> - URL prefix for uploaded files (default: ~/) - (default: "~/")`

All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
142 changes: 9 additions & 133 deletions script/debug-id.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,12 @@
/**
* Debug ID injection for JavaScript sourcemaps.
* Debug ID injection — re-exports from src/lib/sourcemap/debug-id.ts.
*
* Injects Sentry debug IDs into JavaScript files and their companion
* sourcemaps for reliable server-side stack trace resolution. Debug IDs
* replace fragile filename/release-based sourcemap matching with a
* deterministic UUID embedded in both the JS file and its sourcemap.
*
* The UUID algorithm and runtime snippet are byte-for-byte compatible
* with `@sentry/bundler-plugin-core`'s `stringToUUID` and
* `getDebugIdSnippet` — see ECMA-426 (Source Map Format) for the spec.
*/

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

/** Comment prefix used to identify an existing debug ID in a JS file. */
const DEBUGID_COMMENT_PREFIX = "//# debugId=";

/** Regex to extract an existing debug ID from a JS file. */
const EXISTING_DEBUGID_RE = /\/\/# debugId=([0-9a-fA-F-]{36})/;

/**
* Generate a deterministic debug ID (UUID v4 format) from content.
*
* Computes SHA-256 of the input, then formats the first 128 bits as a
* UUID v4 string. Matches `@sentry/bundler-plugin-core`'s `stringToUUID`
* exactly — position 12 is forced to `4` (version), and position 16 is
* forced to one of `8/9/a/b` (variant, RFC 4122 §4.4).
*
* @param content - File content (string or Buffer) to hash
* @returns UUID v4 string, e.g. `"a1b2c3d4-e5f6-4789-abcd-ef0123456789"`
*/
export function contentToDebugId(content: string | Buffer): string {
const hash = createHash("sha256").update(content).digest("hex");
// Position 16 (first char of 5th group in the hash) determines the
// variant nibble. charCodeAt(0) of a hex digit is deterministic.
const v4variant = ["8", "9", "a", "b"][
hash.substring(16, 17).charCodeAt(0) % 4
];
return `${hash.substring(0, 8)}-${hash.substring(8, 12)}-4${hash.substring(13, 16)}-${v4variant}${hash.substring(17, 20)}-${hash.substring(20, 32)}`.toLowerCase();
}

/**
* Build the runtime IIFE snippet that registers a debug ID in
* `globalThis._sentryDebugIds`.
*
* At runtime, the Sentry SDK reads this map (keyed by Error stack traces)
* to associate stack frames with their debug IDs, which are then used to
* look up the correct sourcemap on the server.
*
* The snippet is a single-line IIFE so it only adds one line to the
* sourcemap mappings offset.
*
* @param debugId - The UUID to embed
* @returns Minified IIFE string (single line, starts with `;`)
*/
export function getDebugIdSnippet(debugId: string): string {
return `;!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`;
}

/**
* Inject a Sentry debug ID into a JavaScript file and its companion
* sourcemap.
*
* Performs four mutations:
* 1. Prepends the runtime snippet to the JS file (after any hashbang)
* 2. Appends a `//# debugId=<uuid>` comment to the JS file
* 3. Prepends a `;` to the sourcemap `mappings` (offsets by one line)
* 4. Adds `debug_id` and `debugId` fields to the sourcemap JSON
*
* The operation is **idempotent** — files that already contain a
* `//# debugId=` comment are returned unchanged.
*
* @param jsPath - Path to the JavaScript file
* @param mapPath - Path to the companion `.map` file
* @returns The debug ID that was injected (or already present)
* Build scripts import from here for convenience. The actual
* implementation lives in src/lib/sourcemap/ alongside the ZIP builder
* and injection utilities.
*/
export async function injectDebugId(
jsPath: string,
mapPath: string
): Promise<{ debugId: string }> {
const [jsContent, mapContent] = await Promise.all([
readFile(jsPath, "utf-8"),
readFile(mapPath, "utf-8"),
]);

// Idempotent: if the JS file already has a debug ID, extract and return it
const existingMatch = jsContent.match(EXISTING_DEBUGID_RE);
if (existingMatch) {
return { debugId: existingMatch[1] };
}

// Generate debug ID from the sourcemap content (deterministic)
const debugId = contentToDebugId(mapContent);
const snippet = getDebugIdSnippet(debugId);

// --- Mutate JS file ---
// Preserve hashbang if present, insert snippet after it
let newJs: string;
if (jsContent.startsWith("#!")) {
const newlineIdx = jsContent.indexOf("\n");
// Handle hashbang without trailing newline (entire file is the #! line)
const splitAt = newlineIdx === -1 ? jsContent.length : newlineIdx + 1;
const hashbang = jsContent.slice(0, splitAt);
const rest = jsContent.slice(splitAt);
const sep = newlineIdx === -1 ? "\n" : "";
newJs = `${hashbang}${sep}${snippet}\n${rest}`;
} else {
newJs = `${snippet}\n${jsContent}`;
}
// Append debug ID comment at the end
newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`;

// --- Mutate sourcemap ---
// Parse, adjust mappings, add debug ID fields
const map = JSON.parse(mapContent) as {
mappings: string;
debug_id?: string;
debugId?: string;
};
// Prepend one `;` to mappings — tells decoders "no mappings for the
// first line" (the injected snippet line). Each `;` in VLQ mappings
// represents a line boundary.
map.mappings = `;${map.mappings}`;
map.debug_id = debugId;
map.debugId = debugId;

// Write both files concurrently
await Promise.all([
writeFile(jsPath, newJs),
writeFile(mapPath, JSON.stringify(map)),
]);

return { debugId };
}
export {
contentToDebugId,
getDebugIdSnippet,
injectDebugId,
} from "../src/lib/sourcemap/debug-id.js";
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { listCommand as projectListCommand } from "./commands/project/list.js";
import { repoRoute } from "./commands/repo/index.js";
import { listCommand as repoListCommand } from "./commands/repo/list.js";
import { schemaCommand } from "./commands/schema.js";
import { sourcemapRoute } from "./commands/sourcemap/index.js";
import { spanRoute } from "./commands/span/index.js";
import { listCommand as spanListCommand } from "./commands/span/list.js";
import { teamRoute } from "./commands/team/index.js";
Expand Down Expand Up @@ -56,6 +57,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
repos: "repo",
teams: "team",
logs: "log",

spans: "span",
traces: "trace",
trials: "trial",
Expand All @@ -75,6 +77,8 @@ export const routes = buildRouteMap({
issue: issueRoute,
event: eventRoute,
log: logRoute,
sourcemap: sourcemapRoute,
sourcemaps: sourcemapRoute,
span: spanRoute,
trace: traceRoute,
trial: trialRoute,
Expand Down Expand Up @@ -110,6 +114,7 @@ export const routes = buildRouteMap({
spans: true,
traces: true,
trials: true,
sourcemaps: true,
whoami: true,
},
},
Expand Down
17 changes: 17 additions & 0 deletions src/commands/sourcemap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { buildRouteMap } from "@stricli/core";
import { injectCommand } from "./inject.js";
import { uploadCommand } from "./upload.js";

export const sourcemapRoute = buildRouteMap({
routes: {
inject: injectCommand,
upload: uploadCommand,
},
docs: {
brief: "Manage sourcemaps",
fullDescription:
"Inject debug IDs and upload sourcemaps to Sentry.\n\n" +
"Alias: `sentry sourcemaps` → `sentry sourcemap`",
hideRoute: {},
},
});
120 changes: 120 additions & 0 deletions src/commands/sourcemap/inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* sentry sourcemap inject <dir>
*
* Scan a directory for JavaScript files and their companion sourcemaps,
* then inject Sentry debug IDs for reliable sourcemap resolution.
*/

import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import {
colorTag,
mdKvTable,
renderMarkdown,
} from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
type InjectResult,
injectDirectory,
} from "../../lib/sourcemap/inject.js";

/** Result type for the inject command output. */
type InjectCommandResult = {
modified: number;
skipped: number;
files: InjectResult[];
};

/** Format human-readable output for inject results. */
function formatInjectResult(data: InjectCommandResult): string {
const lines: string[] = [];
lines.push(
mdKvTable([
["Files modified", String(data.modified)],
["Files skipped", String(data.skipped)],
])
);

if (data.files.length > 0) {
lines.push("");
for (const file of data.files) {
const status = file.injected ? "✓" : "–";
lines.push(
`${status} ${file.jsPath} → ${colorTag("muted", file.debugId)}`
);
}
}

return renderMarkdown(lines.join("\n"));
}

export const injectCommand = buildCommand({
docs: {
brief: "Inject debug IDs into JavaScript files and sourcemaps",
fullDescription:
"Scans a directory for .js/.mjs/.cjs files and their companion .map files, " +
"then injects Sentry debug IDs for reliable sourcemap resolution.\n\n" +
"The injection is idempotent — files that already have debug IDs are skipped.\n\n" +
"Usage:\n" +
" sentry sourcemap inject ./dist\n" +
" sentry sourcemap inject ./build --ext .js,.mjs\n" +
" sentry sourcemap inject ./out --dry-run",
},
output: {
human: formatInjectResult,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
brief: "Directory to scan for JS + sourcemap pairs",
parse: String,
placeholder: "directory",
},
],
},
flags: {
ext: {
kind: "parsed",
parse: String,
brief:
"Comma-separated file extensions to process (default: .js,.cjs,.mjs)",
optional: true,
},
"dry-run": {
kind: "boolean",
brief: "Show what would be modified without writing",
optional: true,
default: false,
},
},
},
async *func(
this: SentryContext,
flags: { ext?: string; "dry-run"?: boolean },
dir: string
) {
const extensions = flags.ext?.split(",").map((e) => e.trim());
const results = await injectDirectory(dir, {
extensions,
dryRun: flags["dry-run"],
});

const modified = results.filter((r) => r.injected).length;
const skipped = results.length - modified;

yield new CommandOutput<InjectCommandResult>({
modified,
skipped,
files: results,
});

if (modified > 0) {
return {
hint: "Run `sentry sourcemap upload` to upload the injected files to Sentry",
};
}
return {};
},
});
Loading
Loading