// ---------------------------------------------------------------------------// Stable Sentry grouping across deploys.//// Every bundle we ship names its chunks `<name>-<contentHash>.js`, and the// hash rotates on every build. When a stack frame is not resolved back to// source (no uploaded sourcemap for that release), Sentry groups on those// minified names, so the SAME error opens a brand-new issue after each// deploy — one bug spread over N issues, no counts, no regression history.//// The fix is a `beforeSend` that computes an explicit `fingerprint` from the// event's grouping inputs with the volatile hash segment removed. The event// itself is left untouched: filenames still carry their hashes so server-side// sourcemap resolution keeps working — only the grouping key is normalized.//// Deliberately narrow: a fingerprint is returned ONLY for events that actually// carry a content hash. Everything else keeps Sentry's default grouping.// ---------------------------------------------------------------------------/** The shape of a Sentry stack frame this module reads. Structural on purpose: * the same helper serves @sentry/cloudflare, /electron and /browser events. */export type GroupingFrame = { readonly filename?: string | undefined; readonly abs_path?: string | undefined; readonly module?: string | undefined; readonly function?: string | undefined; readonly in_app?: boolean | undefined;};export type GroupingExceptionValue = { readonly type?: string | undefined; readonly value?: string | undefined; readonly stacktrace?: { readonly frames?: readonly GroupingFrame[] | undefined } | undefined; readonly mechanism?: { readonly type?: string | undefined } | undefined;};export type GroupingEvent = { readonly culprit?: string | undefined; readonly exception?: { readonly values?: readonly GroupingExceptionValue[] | undefined };};/** * A `-XXXXXXXX` tail that ends a path segment — i.e. is followed by nothing, * by file extensions, or by a delimiter such as the `)` in a Sentry culprit * (`orElse(execution-rate-limit-BAuwphPA)`). Eight characters is the Vite and * Rollup default; the hash alphabet includes `-` and `_`, so a hash can itself * contain a dash (`BCr-oWx4`) and the whole 8-char tail must go at once. */const HASH_TAIL = /-([A-Za-z0-9_-]{8})(?=(?:\.[A-Za-z0-9]+)*(?:$|[)\]}'"\s,:;?#]))/g;/** * Whether an 8-character segment reads like a content hash rather than a word. * Build hashes are random base64url, so they mix cases and digits; real name * segments (`callback`, `grouping`, `provider`) are all lowercase. Requiring * that mix is what keeps `oauth-callback` from collapsing to `oauth`. The cost * is that the occasional word-shaped hash (one capital, no digits) is left * alone and keeps re-grouping — 1 of 147 chunks in a sample desktop build. A * missed normalization is recoverable; a wrongly merged issue is not. */const looksLikeContentHash = (segment: string): boolean => { let upper = 0; let digits = 0; for (const char of segment) { if (char >= "A" && char <= "Z") upper += 1; else if (char >= "0" && char <= "9") digits += 1; } return upper >= 2 || (upper >= 1 && digits >= 1) || digits >= 2;};/** * Remove build content hashes from a path, module name or culprit string, * leaving every other character in place. */export const stripContentHashes = (value: string): string => value.replace(HASH_TAIL, (match, segment: string) => looksLikeContentHash(segment) ? "" : match, );/** True when the value carries at least one build content hash. */export const containsContentHash = (value: string): boolean => stripContentHashes(value) !== value;/** * Minified identifiers rotate with every build exactly like chunk hashes, so a * fingerprint containing one is no more stable than the hash it replaced. Names * of three characters or fewer are treated as minified and dropped; the frame * still contributes its module. */const MINIFIED_FUNCTION = /^[A-Za-z_$][A-Za-z0-9_$]{0,2}$/;const usableFunction = (name: string | undefined): string | undefined => { if (!name || name === "<anonymous>" || name === "?") return undefined; return MINIFIED_FUNCTION.test(name) ? undefined : name;};/** Last path segment, without query or fragment. Chunk paths are served from * host- and port-specific origins (`http://127.0.0.1:4789/assets/…`), which are * volatile in their own right. */const basename = (path: string): string => { const clean = path.split(/[?#]/)[0] ?? path; const segments = clean.split(/[/\\]/); return segments[segments.length - 1] || clean;};const frameLocation = (frame: GroupingFrame): string | undefined => { if (frame.module) return stripContentHashes(frame.module); const path = frame.filename ?? frame.abs_path; return path ? stripContentHashes(basename(path)) : undefined;};const frameKey = (frame: GroupingFrame): string | undefined => { const location = frameLocation(frame); if (!location) return undefined; const fn = usableFunction(frame.function); return fn ? `${fn}@${location}` : location;};const frameHasContentHash = (frame: GroupingFrame): boolean => containsContentHash(frame.module ?? "") || containsContentHash(frame.filename ?? "") || containsContentHash(frame.abs_path ?? "");/** * How many frames from the top of the stack enter the fingerprint. Bundlers * emit several unrelated chunks under the same name (nine different * `dist-<hash>.js` in one desktop build), so the top frame alone would merge * unrelated vendor code; the caller chain is what keeps them apart. The * resulting key is still strictly coarser than Sentry's default input — same * frames, minus the volatile hash and minified identifiers — so this can only * merge issues, never split one further. */const FINGERPRINT_FRAMES = 8;/** * A deploy-stable fingerprint for an event whose grouping input carries a build * content hash, or `undefined` to leave Sentry's default grouping in place. */export const stableGroupingFingerprint = (event: GroupingEvent): readonly string[] | undefined => { const values = event.exception?.values ?? []; // Sentry orders chained exceptions innermost-first; the last entry is the one // the issue is titled with. const primary = values[values.length - 1]; if (!primary) return undefined; const frames = primary.stacktrace?.frames ?? []; const culprit = event.culprit ?? ""; if (!frames.some(frameHasContentHash) && !containsContentHash(culprit)) return undefined; const inApp = frames.filter((frame) => frame.in_app !== false); const considered = (inApp.length > 0 ? inApp : frames).slice(-FINGERPRINT_FRAMES).reverse(); const keys = considered.flatMap((frame) => { const key = frameKey(frame); return key ? [key] : []; }); const type = primary.type ?? "Error"; if (keys.length > 0) return [type, ...keys]; return culprit ? [type, stripContentHashes(culprit)] : undefined;};/** * The whole `beforeSend` contract: pin the deploy-stable fingerprint when the * event has a volatile grouping input, and forward the event untouched when it * does not. Every call site (cloud worker/DO, desktop main, desktop renderer) * installs this one symbol rather than its own copy, so the wiring is covered * by the tests below instead of being retyped per process. */export const withStableGroupingFingerprint = <T extends GroupingEvent>(event: T): T => { const fingerprint = stableGroupingFingerprint(event); return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event;};
// ---------------------------------------------------------------------------
// Stable Sentry grouping across deploys.
//
// Every bundle we ship names its chunks `<name>-<contentHash>.js`, and the
// hash rotates on every build. When a stack frame is not resolved back to
// source (no uploaded sourcemap for that release), Sentry groups on those
// minified names, so the SAME error opens a brand-new issue after each
// deploy — one bug spread over N issues, no counts, no regression history.
//
// The fix is a `beforeSend` that computes an explicit `fingerprint` from the
// event's grouping inputs with the volatile hash segment removed. The event
// itself is left untouched: filenames still carry their hashes so server-side
// sourcemap resolution keeps working — only the grouping key is normalized.
//
// Deliberately narrow: a fingerprint is returned ONLY for events that actually
// carry a content hash. Everything else keeps Sentry's default grouping.
// ---------------------------------------------------------------------------
/** The shape of a Sentry stack frame this module reads. Structural on purpose:
* the same helper serves @sentry/cloudflare, /electron and /browser events. */
export type GroupingFrame = {
readonly filename?: string | undefined;
readonly abs_path?: string | undefined;
readonly module?: string | undefined;
readonly function?: string | undefined;
readonly in_app?: boolean | undefined;
};
export type GroupingExceptionValue = {
readonly type?: string | undefined;
readonly value?: string | undefined;
readonly stacktrace?: { readonly frames?: readonly GroupingFrame[] | undefined } | undefined;
readonly mechanism?: { readonly type?: string | undefined } | undefined;
};
export type GroupingEvent = {
readonly culprit?: string | undefined;
readonly exception?: { readonly values?: readonly GroupingExceptionValue[] | undefined };
};
/**
* A `-XXXXXXXX` tail that ends a path segment — i.e. is followed by nothing,
* by file extensions, or by a delimiter such as the `)` in a Sentry culprit
* (`orElse(execution-rate-limit-BAuwphPA)`). Eight characters is the Vite and
* Rollup default; the hash alphabet includes `-` and `_`, so a hash can itself
* contain a dash (`BCr-oWx4`) and the whole 8-char tail must go at once.
*/
const HASH_TAIL = /-([A-Za-z0-9_-]{8})(?=(?:\.[A-Za-z0-9]+)*(?:$|[)\]}'"\s,:;?#]))/g;
/**
* Whether an 8-character segment reads like a content hash rather than a word.
* Build hashes are random base64url, so they mix cases and digits; real name
* segments (`callback`, `grouping`, `provider`) are all lowercase. Requiring
* that mix is what keeps `oauth-callback` from collapsing to `oauth`. The cost
* is that the occasional word-shaped hash (one capital, no digits) is left
* alone and keeps re-grouping — 1 of 147 chunks in a sample desktop build. A
* missed normalization is recoverable; a wrongly merged issue is not.
*/
const looksLikeContentHash = (segment: string): boolean => {
let upper = 0;
let digits = 0;
for (const char of segment) {
if (char >= "A" && char <= "Z") upper += 1;
else if (char >= "0" && char <= "9") digits += 1;
}
return upper >= 2 || (upper >= 1 && digits >= 1) || digits >= 2;
};
/**
* Remove build content hashes from a path, module name or culprit string,
* leaving every other character in place.
*/
export const stripContentHashes = (value: string): string =>
value.replace(HASH_TAIL, (match, segment: string) =>
looksLikeContentHash(segment) ? "" : match,
);
/** True when the value carries at least one build content hash. */
export const containsContentHash = (value: string): boolean => stripContentHashes(value) !== value;
/**
* Minified identifiers rotate with every build exactly like chunk hashes, so a
* fingerprint containing one is no more stable than the hash it replaced. Names
* of three characters or fewer are treated as minified and dropped; the frame
* still contributes its module.
*/
const MINIFIED_FUNCTION = /^[A-Za-z_$][A-Za-z0-9_$]{0,2}$/;
const usableFunction = (name: string | undefined): string | undefined => {
if (!name || name === "<anonymous>" || name === "?") return undefined;
return MINIFIED_FUNCTION.test(name) ? undefined : name;
};
/** Last path segment, without query or fragment. Chunk paths are served from
* host- and port-specific origins (`http://127.0.0.1:4789/assets/…`), which are
* volatile in their own right. */
const basename = (path: string): string => {
const clean = path.split(/[?#]/)[0] ?? path;
const segments = clean.split(/[/\\]/);
return segments[segments.length - 1] || clean;
};
const frameLocation = (frame: GroupingFrame): string | undefined => {
if (frame.module) return stripContentHashes(frame.module);
const path = frame.filename ?? frame.abs_path;
return path ? stripContentHashes(basename(path)) : undefined;
};
const frameKey = (frame: GroupingFrame): string | undefined => {
const location = frameLocation(frame);
if (!location) return undefined;
const fn = usableFunction(frame.function);
return fn ? `${fn}@${location}` : location;
};
const frameHasContentHash = (frame: GroupingFrame): boolean =>
containsContentHash(frame.module ?? "") ||
containsContentHash(frame.filename ?? "") ||
containsContentHash(frame.abs_path ?? "");
/**
* How many frames from the top of the stack enter the fingerprint. Bundlers
* emit several unrelated chunks under the same name (nine different
* `dist-<hash>.js` in one desktop build), so the top frame alone would merge
* unrelated vendor code; the caller chain is what keeps them apart. The
* resulting key is still strictly coarser than Sentry's default input — same
* frames, minus the volatile hash and minified identifiers — so this can only
* merge issues, never split one further.
*/
const FINGERPRINT_FRAMES = 8;
/**
* A deploy-stable fingerprint for an event whose grouping input carries a build
* content hash, or `undefined` to leave Sentry's default grouping in place.
*/
export const stableGroupingFingerprint = (event: GroupingEvent): readonly string[] | undefined => {
const values = event.exception?.values ?? [];
// Sentry orders chained exceptions innermost-first; the last entry is the one
// the issue is titled with.
const primary = values[values.length - 1];
if (!primary) return undefined;
const frames = primary.stacktrace?.frames ?? [];
const culprit = event.culprit ?? "";
if (!frames.some(frameHasContentHash) && !containsContentHash(culprit)) return undefined;
const inApp = frames.filter((frame) => frame.in_app !== false);
const considered = (inApp.length > 0 ? inApp : frames).slice(-FINGERPRINT_FRAMES).reverse();
const keys = considered.flatMap((frame) => {
const key = frameKey(frame);
return key ? [key] : [];
});
const type = primary.type ?? "Error";
if (keys.length > 0) return [type, ...keys];
return culprit ? [type, stripContentHashes(culprit)] : undefined;
};
/**
* The whole `beforeSend` contract: pin the deploy-stable fingerprint when the
* event has a volatile grouping input, and forward the event untouched when it
* does not. Every call site (cloud worker/DO, desktop main, desktop renderer)
* installs this one symbol rather than its own copy, so the wiring is covered
* by the tests below instead of being retyped per process.
*/
export const withStableGroupingFingerprint = <T extends GroupingEvent>(event: T): T => {
const fingerprint = stableGroupingFingerprint(event);
return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event;
};