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
8 changes: 8 additions & 0 deletions apps/cli-docs/src/content/docs/migrating-from-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,14 @@ Two behavioral differences to be aware of:
`--wait`, `--wait-for`, `--no-sourcemap-reference`, `--debug-id-reference`,
`--bundle`, `--bundle-sourcemap`, `--strict`. `sourcemap inject` also drops
`--release`. Run `sentry sourcemap upload --help` for the current set.
- **`--debug-id-reference` is now automatic:** in v3 that flag let `sourcemaps
upload` take the debug ID from the linked sourcemap when it couldn't verify
one in the bundle itself, for example in binary bundles.
v4 does this by default. If a sourcemap already carries `debug_id`
— from a bundler plugin configured with `sourcemaps.disable:
'disable-upload'`, or copied across by a tool like React Native's
`copy-debugid.js` — `inject` and `upload` adopt that ID rather than generating a
new one.

See [`sourcemap`](/commands/sourcemap/) for details.

Expand Down
57 changes: 54 additions & 3 deletions packages/cli/src/lib/sourcemap/debug-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { UUID_DASH_RE } from "../hex-id.js";
import { logger } from "../logger.js";
import {
type DecodedInlineMap,
Expand All @@ -35,6 +36,29 @@ const DEBUGID_COMMENT_PREFIX = "//# debugId=";
/** Regex to extract an existing debug ID from a JS file. @internal */
export const EXISTING_DEBUGID_RE = /\/\/# debugId=([0-9a-fA-F-]{36})/;

/**
* Read a pre-existing debug ID off a parsed sourcemap.
*
* `debug_id` wins over `debugId` when both are present. A value that is not a
* well-formed UUID is treated as absent so the caller falls through to minting.
*
* @param map - A parsed sourcemap object (any shape; fields are probed)
* @returns The debug ID, or `undefined` when the map carries none
* @internal
*/
export function readSourcemapDebugId(map: unknown): string | undefined {
if (typeof map !== "object" || map === null) {
return;
}
const { debug_id: snake, debugId: camel } = map as SourcemapJson;
for (const candidate of [snake, camel]) {
if (typeof candidate === "string" && UUID_DASH_RE.test(candidate)) {
return candidate;
}
}
return;
}

/**
* Generate a deterministic debug ID (UUID v4 format) from content.
*
Expand Down Expand Up @@ -118,7 +142,9 @@ export function prependDebugIdSnippet(
* registered in source code (`constants.ts`) instead of via the IIFE.
*
* The operation is **idempotent** — files that already contain a
* `//# debugId=` comment are returned unchanged.
* `//# debugId=` comment are returned unchanged. A debug ID already present
* on the sourcemap is likewise adopted as-is, leaving both files untouched
* (see {@link readSourcemapDebugId}).
*
* @param jsPath - Path to the JavaScript file
* @param mapPath - Path to the companion `.map` file
Expand All @@ -142,6 +168,19 @@ export async function injectDebugId(
return { debugId: existingMatch[1], wasInjected: false };
}

const map = JSON.parse(mapContent) as SourcemapJson;

// The JS carries no comment, but the map may already have been stamped by a
// bundler plugin that intentionally left the bundle alone. Adopt that ID and
// touch neither file: the bundle already registers it via the plugin's own
// `_sentryDebugIds` writer (a second snippet under a different stack key
// would make the runtime mapping ambiguous), and the map's `mappings` line
// up with the un-offset bundle.
const mapDebugId = readSourcemapDebugId(map);
if (mapDebugId) {
return { debugId: mapDebugId, wasInjected: false };
}

// Derive the debug ID from the minified JS content combined with the
// sourcemap content. Hashing both guarantees that distinct minified
// artifacts receive distinct debug IDs even when their sourcemaps are
Expand All @@ -168,7 +207,6 @@ export async function injectDebugId(
newJs += `\n${DEBUGID_COMMENT_PREFIX}${debugId}\n`;

// --- Mutate sourcemap ---
const map = JSON.parse(mapContent) as SourcemapJson;
mutateSourcemap(map, debugId, { offsetMappings: !skipSnippet });

// Write both files concurrently
Expand Down Expand Up @@ -244,7 +282,8 @@ const INLINE_DIRECTIVE_RE =
* place**, so the file stays self-contained. Only the **last** inline
* directive is rewritten.
*
* Idempotent — files already carrying a `//# debugId=` comment are unchanged.
* Idempotent — files already carrying a `//# debugId=` comment are unchanged,
* as are files whose decoded inline map already carries a debug ID.
*
* @param jsPath - Path to the JavaScript file
* @param decoded - The decoded inline sourcemap and its re-encode metadata
Expand Down Expand Up @@ -282,6 +321,18 @@ export async function injectInlineDebugId(
};
}

// Same rule as the external path: a debug ID already stamped on the map by a
// bundler plugin is adopted verbatim, leaving the JS (and its embedded map)
// untouched. Upload the map exactly as decoded.
const mapDebugId = readSourcemapDebugId(decoded.map);
if (mapDebugId) {
return {
debugId: mapDebugId,
wasInjected: false,
injectedMapContent: Buffer.from(decoded.json),
};
}

// Locate the LAST inline directive to rewrite. If it can't be found (the
// discovery parser and this regex disagree on an edge case), abort WITHOUT
// modifying the file. Return an EMPTY debug ID and no map content so the
Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lib/sourcemap/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EXISTING_DEBUGID_RE,
injectDebugId,
injectInlineDebugId,
readSourcemapDebugId,
} from "./debug-id.js";
import {
type DecodedInlineMap,
Expand Down Expand Up @@ -101,17 +102,18 @@ export async function injectDirectory(
for (const { jsPath, map } of filePairs) {
const mapPath = map.kind === "external" ? map.mapPath : undefined;
if (options.dryRun) {
// Check if file already has a debug ID without modifying it
// Resolve the debug ID the real run would use, without modifying
// anything. Mirrors injectDebugId's precedence: the JS comment first,
// then an ID the sourcemap already carries — either means no injection.
const js = await readFile(jsPath, "utf-8");
const existing = js.match(EXISTING_DEBUGID_RE);
const wouldInject = !existing;
const id = existing?.[1] ?? "(pending)";
const existing =
js.match(EXISTING_DEBUGID_RE)?.[1] ?? (await readMapDebugId(map));
results.push({
jsPath,
map,
mapPath,
injected: wouldInject,
debugId: id,
injected: !existing,
debugId: existing ?? "(pending)",
});
continue;
}
Expand Down Expand Up @@ -139,6 +141,31 @@ export async function injectDirectory(
return results;
}

/**
* Read a debug ID already present on a discovered sourcemap, if any.
*
* Non-fatal: an unreadable or malformed map yields `undefined`, so callers
* fall through to minting a content-derived ID exactly as before.
*
* @param map - The discovered sourcemap location
* @returns The map's debug ID, or `undefined` when it carries none
*/
async function readMapDebugId(map: MapSource): Promise<string | undefined> {
if (map.kind === "inline") {
return readSourcemapDebugId(map.decoded.map);
}
try {
return readSourcemapDebugId(
JSON.parse(await readFile(map.mapPath, "utf-8"))
);
} catch (err) {
log.warn(
`could not read a debug ID from ${map.mapPath}: ${err instanceof Error ? err.message : String(err)}`
);
return;
}
}

/** A discovered JS + sourcemap pair. */
export type FilePair = { jsPath: string; map: MapSource };

Expand Down Expand Up @@ -781,6 +808,12 @@ export async function resolveDirectorySourcemaps(
} catch (err) {
log.debug(`failed to read JS file for debug ID: ${jsPath}`, err);
}
// No comment in the JS: the linked map may still carry the ID a bundler
// plugin stamped at build time, which injection would adopt rather than
// replace.
if (!debugId && map) {
debugId = await readMapDebugId(map);
}

results.push({
jsPath,
Expand Down
Loading
Loading