import { expect, test } from "@effect/vitest"; import { containsContentHash, stableGroupingFingerprint, stripContentHashes, withStableGroupingFingerprint, type GroupingEvent, } from "./sentry-grouping"; // Two builds of the same source: only the Vite content hash differs. const workerFrame = (hash: string) => ({ filename: `/assets/execution-rate-limit-${hash}.js`, module: `execution-rate-limit-${hash}`, function: "timeoutOrElse", in_app: true, }); const workerEvent = (hash: string): GroupingEvent => ({ culprit: `timeoutOrElse(execution-rate-limit-${hash})`, exception: { values: [ { type: "GateCheckTimeoutError", value: "balance check timed out", stacktrace: { frames: [workerFrame(hash)] }, }, ], }, }); test("stripContentHashes rewrites chunk hashes and leaves everything else alone", () => { expect(stripContentHashes("/assets/execution-rate-limit-BAuwphPA.js")).toBe( "/assets/execution-rate-limit.js", ); expect(stripContentHashes("/assets/execution-rate-limit-DkcPBbWe.js")).toBe( "/assets/execution-rate-limit.js", ); // Rollup hashes may themselves contain a dash — the whole 8-char tail goes. expect(stripContentHashes("http://127.0.0.1:4789/assets/AddGraphqlIntegration-BCr-oWx4.js")).toBe( "http://127.0.0.1:4789/assets/AddGraphqlIntegration.js", ); // Culprit strings wrap the chunk name in parentheses. expect(stripContentHashes("U(assets/atoms-Yemn7yhP)")).toBe("U(assets/atoms)"); expect(stripContentHashes("W(assets/atoms-CeCENfWa)")).toBe("W(assets/atoms)"); }); test("stripContentHashes does not eat real name segments", () => { // 8 lowercase letters is a word, not a hash. expect(stripContentHashes("/src/auth/oauth-callback.ts")).toBe("/src/auth/oauth-callback.ts"); expect(stripContentHashes("apps/cloud/src/engine/execution-gate.ts")).toBe( "apps/cloud/src/engine/execution-gate.ts", ); expect(stripContentHashes("/assets/execution-rate-limit.js")).toBe( "/assets/execution-rate-limit.js", ); expect(stripContentHashes("")).toBe(""); }); test("stripContentHashes keeps genuinely different modules distinct", () => { expect(stripContentHashes("/assets/atoms-Yemn7yhP.js")).not.toBe( stripContentHashes("/assets/router-CeCENfWa.js"), ); expect(stripContentHashes("/assets/atoms-Yemn7yhP.js")).not.toBe( stripContentHashes("/assets/atoms-shell-CeCENfWa.js"), ); }); // Merging two unrelated bugs into one issue is unrecoverable, so the "is this // a hash?" threshold is pinned from both sides: a segment needs two of the // three hash signals (uppercase, uppercase+digit, digits) before it is eaten. // Loosening it to one signal — the tempting fix for the word-shaped hashes // this deliberately misses — silently collapses real chunk names. test("a name segment one signal short of a hash is left in place", () => { // One capital, no digits. expect(stripContentHashes("/assets/connections-Settings.js")).toBe( "/assets/connections-Settings.js", ); // One digit, no capitals. expect(stripContentHashes("/assets/storage-s3client.js")).toBe("/assets/storage-s3client.js"); // ...and neither may be mistaken for the chunk it would collapse onto. expect(stripContentHashes("/assets/connections-Settings.js")).not.toBe( stripContentHashes("/assets/connections-BAuwphPA.js"), ); }); test("a segment with two hash signals is eaten", () => { expect(stripContentHashes("/assets/chunk-AbcdefgH.js")).toBe("/assets/chunk.js"); expect(stripContentHashes("/assets/chunk-Abcdefg1.js")).toBe("/assets/chunk.js"); expect(stripContentHashes("/assets/chunk-abcdef12.js")).toBe("/assets/chunk.js"); }); test("containsContentHash detects only hashed paths", () => { expect(containsContentHash("/assets/atoms-Yemn7yhP.js")).toBe(true); expect(containsContentHash("apps/cloud/src/engine/execution-gate.ts")).toBe(false); }); test("the same logical frame fingerprints identically across two deploys", () => { const first = stableGroupingFingerprint(workerEvent("BAuwphPA")); const second = stableGroupingFingerprint(workerEvent("DkcPBbWe")); expect(first).toBeDefined(); expect(first).toEqual(second); expect(first).toEqual(["GateCheckTimeoutError", "timeoutOrElse@execution-rate-limit"]); }); test("different modules keep different fingerprints", () => { const other: GroupingEvent = { exception: { values: [ { type: "GateCheckTimeoutError", stacktrace: { frames: [ { filename: "/assets/atoms-Yemn7yhP.js", module: "atoms-Yemn7yhP", function: "loadConnections", in_app: true, }, ], }, }, ], }, }; expect(stableGroupingFingerprint(other)).not.toEqual( stableGroupingFingerprint(workerEvent("BAuwphPA")), ); }); test("minified single-letter frame functions are left out of the fingerprint", () => { // Minified identifiers rotate with every build exactly like chunk hashes, so // keeping them would re-split the issue on the next deploy. const atoms = (fn: string, hash: string): GroupingEvent => ({ exception: { values: [ { type: "TypeError", stacktrace: { frames: [ { filename: `/assets/atoms-${hash}.js`, module: `atoms-${hash}`, function: fn }, ], }, }, ], }, }); expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual( stableGroupingFingerprint(atoms("W", "CeCENfWa")), ); expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual(["TypeError", "atoms"]); }); // A single build emits many `dist-.js` chunks from unrelated packages, // so the crashing frame alone is not a safe key — two different bugs would // land in one issue. Frames are oldest-first, so the shared chunk is last. const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ exception: { values: [ { type: "TypeError", stacktrace: { frames: [ { module: `${caller}-${hash}`, function: caller, in_app: true }, { module: `dist-${hash}`, function: "throwHelper", in_app: true }, ], }, }, ], }, }); test("two vendor chunks sharing a name stay apart on their callers", () => { const upload = stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2")); const render = stableGroupingFingerprint(vendorEvent("renderMarkdown", "BQZkXWT2")); // The crashing frame is identical in both — only the caller chain separates // them, so a fingerprint built from the top frame alone would over-merge. expect(upload?.slice(0, 2)).toEqual(["TypeError", "throwHelper@dist"]); expect(render?.slice(0, 2)).toEqual(upload?.slice(0, 2)); expect(upload).not.toEqual(render); expect(upload).toEqual(["TypeError", "throwHelper@dist", "uploadArtifact@uploadArtifact"]); }); test("the caller chain is itself deploy-stable", () => { // Every frame in the chain must lose its hash, not just the crashing one. expect(stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2"))).toEqual( stableGroupingFingerprint(vendorEvent("uploadArtifact", "Yemn7yhP")), ); }); test("events without a chunk hash keep Sentry's default grouping", () => { const unhashed: GroupingEvent = { culprit: "checkExecutionBalance(execution-gate.ts)", exception: { values: [ { type: "AutumnError", stacktrace: { frames: [ { filename: "/apps/cloud/src/engine/execution-gate.ts", function: "checkExecutionBalance", in_app: true, }, ], }, }, ], }, }; expect(stableGroupingFingerprint(unhashed)).toBeUndefined(); expect(stableGroupingFingerprint({})).toBeUndefined(); expect(stableGroupingFingerprint({ exception: { values: [] } })).toBeUndefined(); }); test("a hashed culprit alone is enough to pin the fingerprint", () => { const culpritOnly: GroupingEvent = { culprit: "orElse(execution-rate-limit-BAuwphPA)", exception: { values: [{ type: "TypeError", value: "destroyed" }] }, }; expect(stableGroupingFingerprint(culpritOnly)).toEqual([ "TypeError", "orElse(execution-rate-limit)", ]); }); // The wrapper every process installs as its `beforeSend`. `fingerprint` and // `tags` stand in for the fields a real Sentry event carries around it. type SentEvent = GroupingEvent & { readonly fingerprint?: readonly string[] | undefined; readonly tags?: Record | undefined; }; test("withStableGroupingFingerprint pins the key and changes nothing else", () => { const input: SentEvent = { ...workerEvent("BAuwphPA"), tags: { a: "b" } }; const hashed = withStableGroupingFingerprint(input); expect(hashed.fingerprint).toEqual([ "GateCheckTimeoutError", "timeoutOrElse@execution-rate-limit", ]); // The event still carries its hashed filename, or server-side sourcemap // resolution would stop finding the release's artifacts. expect(hashed.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( "/assets/execution-rate-limit-BAuwphPA.js", ); expect(hashed.tags).toEqual({ a: "b" }); }); test("withStableGroupingFingerprint forwards an unhashed event untouched", () => { const plain: SentEvent = { culprit: "checkExecutionBalance(execution-gate.ts)" }; const out = withStableGroupingFingerprint(plain); expect(out).toBe(plain); expect("fingerprint" in out).toBe(false); }); test("the outermost exception drives the fingerprint", () => { // Sentry orders `values` innermost-first; the last entry is the one whose // type the issue is titled with. const chained: GroupingEvent = { exception: { values: [ { type: "InnerError", stacktrace: { frames: [workerFrame("BAuwphPA")] } }, { type: "OuterError", stacktrace: { frames: [workerFrame("BAuwphPA")] } }, ], }, }; expect(stableGroupingFingerprint(chained)?.[0]).toBe("OuterError"); });