Unified · Split
| @@ -16,7 +16,7 @@ import { Cause, Effect, Layer } from "effect"; · 15 unmodified lines | ||
| 16 | 16 | import type * as Tracer from "effect/Tracer"; |
| 17 | 17 | |
| 18 | 18 | import { ErrorCapture } from "@executor-js/api"; |
| 19 | −import { stableGroupingFingerprint, type GroupingEvent } from "@executor-js/sdk/sentry-grouping"; | |
| 19 | +import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; | |
| 20 | 20 | |
| 21 | 21 | // Drizzle/postgres-js include the failing SQL (params + bound values) in |
| 22 | 22 | // their error message. For OpenAPI source inserts that's 1MB+ of spec |
| @@ -148,19 +148,13 @@ export const beforeSendWithOtelCorrelation = ( · 125 unmodified lines | ||
| 148 | 148 | }; |
| 149 | 149 | |
| 150 | 150 | /** |
| 151 | + * The single `beforeSend` the worker and its Durable Objects install. | |
| 152 | + * | |
| 151 | 153 | * The worker ships as content-hashed chunks and its frames are not resolved |
| 152 | 154 | * back to source, so Sentry's default grouping keys on names like |
| 153 | 155 | * `execution-rate-limit-<hash>` and re-opens every issue on the next deploy. |
| 154 | − * Pin a fingerprint with the hash normalized out; events with no hashed | |
| 155 | − * grouping input are left on the default algorithm. | |
| 156 | − */ | |
| 157 | −export const withStableGroupingFingerprint = <T extends GroupingEvent>(event: T): T => { | |
| 158 | − const fingerprint = stableGroupingFingerprint(event); | |
| 159 | − return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | |
| 160 | −}; | |
| 161 | − | |
| 162 | −/** | |
| 163 | − * The single `beforeSend` the worker and its Durable Objects install. | |
| 156 | + * `withStableGroupingFingerprint` pins a key with the hash normalized out; | |
| 157 | + * events with no hashed grouping input are left on the default algorithm. | |
| 164 | 158 | * |
| 165 | 159 | * The two stages are independent and compose in this order: the capture-owner |
| 166 | 160 | * pass decides WHETHER the event is reported at all (a cause the Durable |
| @@ -176,6 +170,30 @@ export const beforeSendCloudEvent = ( · 9 unmodified lines | ||
| 176 | 170 | return reported === null ? null : withStableGroupingFingerprint(reported); |
| 177 | 171 | }; |
| 178 | 172 | |
| 173 | +/** | |
| 174 | + * The Sentry options the worker and every Durable Object install. It lives | |
| 175 | + * beside the `beforeSend` it wires so the composition is covered by | |
| 176 | + * observability.test.ts; `server.ts` only passes this through. | |
| 177 | + * | |
| 178 | + * NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype | |
| 179 | + * and reads every property — including accessors — to find methods to wrap, | |
| 180 | + * which invokes the `sessionId` getter with `this` bound to the prototype | |
| 181 | + * (where `ctx` is undefined) and throws during construction, 500ing every | |
| 182 | + * session create / cold restore. The DO captures its own errors via the | |
| 183 | + * `captureCause` seam (→ Sentry) instead. | |
| 184 | + */ | |
| 185 | +export const cloudSentryOptions = (env: Env) => ({ | |
| 186 | + dsn: env.SENTRY_DSN, | |
| 187 | + tracesSampleRate: 0, | |
| 188 | + enableLogs: true, | |
| 189 | + sendDefaultPii: true, | |
| 190 | + skipOpenTelemetrySetup: true, | |
| 191 | + beforeSend: (event: ErrorEvent) => | |
| 192 | + beforeSendCloudEvent(event, { | |
| 193 | + logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", | |
| 194 | + }), | |
| 195 | +}); | |
| 196 | + | |
| 179 | 197 | export const addCurrentOtelCorrelationTags = < |
| 180 | 198 | T extends { readonly tags?: Record<string, unknown> }, |
| 181 | 199 | >( |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | addCurrentOtelCorrelationTags, |
| 9 | 9 | beforeSendCloudEvent, |
| 10 | 10 | beforeSendWithOtelCorrelation, |
| 11 | + cloudSentryOptions, | |
| 11 | 12 | DO_CAUSE_OWNER_TAG, |
| 12 | 13 | DO_CAUSE_OWNER_VALUE, |
| 13 | 14 | OTEL_SPAN_ID_TAG, |
| @@ -86,8 +87,10 @@ describe("sentryPayloadForCause", () => { · 72 unmodified lines | ||
| 86 | 87 | }); |
| 87 | 88 | |
| 88 | 89 | // Grouping keys are decided inside the Sentry SDK and never appear on any |
| 89 | −// product surface, so the e2e harness cannot observe them; the running | |
| 90 | −// beforeSend itself is covered by e2e/cloud/sentry-otel-correlation.test.ts. | |
| 90 | +// product surface, so the e2e harness cannot observe them. The split is: | |
| 91 | +// e2e/cloud/sentry-otel-correlation.test.ts proves the worker really installs | |
| 92 | +// `cloudSentryOptions.beforeSend` (its correlation payload only exists if that | |
| 93 | +// hook ran), and the tests here prove the hook it installs fingerprints. | |
| 91 | 94 | describe("Sentry grouping", () => { |
| 92 | 95 | // The worker bundle ships as content-hashed chunks, so the only module name |
| 93 | 96 | // Sentry ever sees for a given frame changes on every deploy. |
| @@ -143,6 +146,21 @@ describe("Sentry grouping", () => { · 49 unmodified lines | ||
| 143 | 146 | expect(sent).not.toBeNull(); |
| 144 | 147 | expect(sent?.fingerprint).toBeUndefined(); |
| 145 | 148 | }); |
| 149 | + | |
| 150 | + // The wiring check: this is the exact object handed to `Sentry.withSentry` | |
| 151 | + // and `instrumentDurableObjectWithSentry` in server.ts. If the normalizer is | |
| 152 | + // ever dropped from the hook the worker installs, this fails. | |
| 153 | + it("the options the worker and DOs install carry the fingerprinting hook", () => { | |
| 154 | + const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env); | |
| 155 | + const sent = options.beforeSend(workerEvent("BAuwphPA")); | |
| 156 | + | |
| 157 | + expect(sent?.fingerprint).toEqual([ | |
| 158 | + "GateCheckTimeoutError", | |
| 159 | + "timeoutOrElse@execution-rate-limit", | |
| 160 | + ]); | |
| 161 | + // Same source, next deploy, new chunk hash — one issue, not two. | |
| 162 | + expect(options.beforeSend(workerEvent("DkcPBbWe"))?.fingerprint).toEqual(sent?.fingerprint); | |
| 163 | + }); | |
| 146 | 164 | }); |
| 147 | 165 | |
| 148 | 166 | describe("Sentry OTel correlation", () => { |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { DurableObject } from "cloudflare:workers"; |
| 2 | 2 | import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; |
| 3 | −import type { ErrorEvent } from "@sentry/cloudflare"; | |
| 4 | 3 | import { |
| 5 | 4 | ATTR_HTTP_REQUEST_METHOD, |
| 6 | 5 | ATTR_HTTP_RESPONSE_STATUS_CODE, |
| @@ -19,7 +18,7 @@ import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; · 12 unmodified lines | ||
| 19 | 18 | import { parseTraceparent } from "./mcp/traceparent"; |
| 20 | 19 | import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; |
| 21 | 20 | import { |
| 22 | − beforeSendCloudEvent, | |
| 21 | + cloudSentryOptions, | |
| 23 | 22 | captureCause, |
| 24 | 23 | otelCorrelationContextFromOpenTelemetrySpan, |
| 25 | 24 | SENTRY_EVENT_ID_ATTRIBUTE, |
| @@ -28,28 +27,6 @@ import { · 2 unmodified lines | ||
| 28 | 27 | import { browserTracesResponse } from "./observability/browser-traces"; |
| 29 | 28 | import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; |
| 30 | 29 | |
| 31 | −// --------------------------------------------------------------------------- | |
| 32 | −// Sentry config | |
| 33 | −// --------------------------------------------------------------------------- | |
| 34 | − | |
| 35 | −const sentryOptions = (env: Env) => ({ | |
| 36 | − dsn: env.SENTRY_DSN, | |
| 37 | − tracesSampleRate: 0, | |
| 38 | − enableLogs: true, | |
| 39 | − sendDefaultPii: true, | |
| 40 | − skipOpenTelemetrySetup: true, | |
| 41 | − beforeSend: (event: ErrorEvent) => | |
| 42 | − beforeSendCloudEvent(event, { | |
| 43 | − logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", | |
| 44 | − }), | |
| 45 | − // NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype | |
| 46 | − // and reads every property — including accessors — to find methods to wrap, | |
| 47 | − // which invokes the `sessionId` getter with `this` bound to the prototype | |
| 48 | − // (where `ctx` is undefined) and throws during construction, 500ing every | |
| 49 | − // session create / cold restore. The DO captures its own errors via the | |
| 50 | − // `captureCause` seam (→ Sentry) instead. | |
| 51 | −}); | |
| 52 | − | |
| 53 | 30 | // --------------------------------------------------------------------------- |
| 54 | 31 | // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the |
| 55 | 32 | // client inside the DO isolate, which plain `Sentry.captureException` cannot |
| @@ -58,7 +35,7 @@ const sentryOptions = (env: Env) => ({ · 2 unmodified lines | ||
| 58 | 35 | // --------------------------------------------------------------------------- |
| 59 | 36 | |
| 60 | 37 | export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( |
| 61 | − sentryOptions, | |
| 38 | + cloudSentryOptions, | |
| 62 | 39 | McpSessionDOBase, |
| 63 | 40 | ); |
| 64 | 41 | |
| @@ -458,4 +435,4 @@ const cloudflareHandler: ExportedHandler<Env> = { · 393 unmodified lines | ||
| 458 | 435 | }, |
| 459 | 436 | }; |
| 460 | 437 | |
| 461 | −export default Sentry.withSentry(sentryOptions, cloudflareHandler); | |
| 438 | +export default Sentry.withSentry(cloudSentryOptions, cloudflareHandler); | |
| @@ -1,11 +1,19 @@ | ||
| 1 | 1 | import { expect, test } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { crashReportFingerprint, type CrashEvent } from "./crash-fingerprint"; | |
| 3 | +import { | |
| 4 | + crashReportFingerprint, | |
| 5 | + withCrashReportFingerprint, | |
| 6 | + type CrashEvent, | |
| 7 | +} from "./crash-fingerprint"; | |
| 8 | + | |
| 9 | +/** A crash event as Sentry hands it to `beforeSend` — with the grouping key | |
| 10 | + * slot the hook is allowed to fill. */ | |
| 11 | +type SentCrashEvent = CrashEvent & { readonly fingerprint?: readonly string[] | undefined }; | |
| 4 | 12 | |
| 5 | 13 | // Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps |
| 6 | 14 | // running. Sentry titles each one with the faulting load address, so one |
| 7 | 15 | // condition arrives as a new issue every time the address moves. |
| 8 | −const softAssertEvent = (address: string): CrashEvent => ({ | |
| 16 | +const softAssertEvent = (address: string): SentCrashEvent => ({ | |
| 9 | 17 | exception: { |
| 10 | 18 | values: [ |
| 11 | 19 | { |
| @@ -86,6 +94,21 @@ test("renderer chunk hashes are normalized out of the fingerprint", () => { · 74 unmodified lines | ||
| 86 | 94 | expect(release1).toEqual(release2); |
| 87 | 95 | }); |
| 88 | 96 | |
| 97 | +// `withCrashReportFingerprint` is the function object diagnostics.ts installs | |
| 98 | +// as its `beforeSend`, so these assertions cover the main-process wiring and | |
| 99 | +// not just the classifier behind it. | |
| 100 | +test("the main-process beforeSend pins the key and forwards the event", () => { | |
| 101 | + const collapsed = withCrashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5")); | |
| 102 | + expect(collapsed.fingerprint).toEqual(["chromium-dump-without-crashing"]); | |
| 103 | + // Nothing else about the event is touched — this is grouping only. | |
| 104 | + expect(collapsed.exception?.values?.[0]?.value).toBe("Simulated Exception / 0x00000001a2b3c4d5"); | |
| 105 | + | |
| 106 | + const untouched: SentCrashEvent = { | |
| 107 | + exception: { values: [{ type: "EXC_CRASH", stacktrace: { frames: [{ function: "abort" }] } }] }, | |
| 108 | + }; | |
| 109 | + expect(withCrashReportFingerprint(untouched)).toBe(untouched); | |
| 110 | +}); | |
| 111 | + | |
| 89 | 112 | test("events with no volatile grouping input are left alone", () => { |
| 90 | 113 | expect(crashReportFingerprint({})).toBeUndefined(); |
| 91 | 114 | expect( |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import { |
| 18 | 18 | stableGroupingFingerprint, |
| 19 | + withStableGroupingFingerprint, | |
| 19 | 20 | type GroupingEvent, |
| 20 | 21 | type GroupingFrame, |
| 21 | 22 | } from "@executor-js/sdk/sentry-grouping"; |
| @@ -37,3 +38,15 @@ export const crashReportFingerprint = (event: CrashEvent): readonly string[] | u · 15 unmodified lines | ||
| 37 | 38 | if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT]; |
| 38 | 39 | return stableGroupingFingerprint(event); |
| 39 | 40 | }; |
| 41 | + | |
| 42 | +/** | |
| 43 | + * The `beforeSend` the Electron main process installs. Grouping only — an | |
| 44 | + * event with no volatile grouping input is forwarded unchanged. | |
| 45 | + */ | |
| 46 | +export const withCrashReportFingerprint = <T extends CrashEvent>(event: T): T => { | |
| 47 | + const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []); | |
| 48 | + if (frames.some(isSoftAssertFrame)) { | |
| 49 | + return { ...event, fingerprint: [CHROMIUM_SOFT_ASSERT_FINGERPRINT] }; | |
| 50 | + } | |
| 51 | + return withStableGroupingFingerprint(event); | |
| 52 | +}; | |
| @@ -23,7 +23,7 @@ import { dirname, join } from "node:path"; · 22 unmodified lines | ||
| 23 | 23 | import { app, crashReporter, dialog, shell } from "electron"; |
| 24 | 24 | import log from "electron-log/main.js"; |
| 25 | 25 | import * as Sentry from "@sentry/electron/main"; |
| 26 | −import { crashReportFingerprint } from "./crash-fingerprint"; | |
| 26 | +import { withCrashReportFingerprint } from "./crash-fingerprint"; | |
| 27 | 27 | import { getServerSettings } from "./settings"; |
| 28 | 28 | |
| 29 | 29 | const sentryDsn = __EXECUTOR_SENTRY_DSN__; |
| @@ -91,10 +91,7 @@ export const initErrorReporting = () => { · 61 unmodified lines | ||
| 91 | 91 | }, |
| 92 | 92 | }, |
| 93 | 93 | // Grouping only — the event is forwarded untouched otherwise. |
| 94 | − beforeSend: (event) => { | |
| 95 | − const fingerprint = crashReportFingerprint(event); | |
| 96 | − return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | |
| 97 | − }, | |
| 94 | + beforeSend: withCrashReportFingerprint, | |
| 98 | 95 | }); |
| 99 | 96 | } else { |
| 100 | 97 | // No DSN baked in — keep native crash dumps local so a user-reported |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | * once initialized — no reporter rewiring needed. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | −import { stableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; | |
| 15 | +import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; | |
| 16 | 16 | |
| 17 | 17 | interface CrashReportingConfig { |
| 18 | 18 | readonly dsn: string; |
| @@ -51,10 +51,7 @@ export const initDesktopCrashReporting = (): void => { · 32 unmodified lines | ||
| 51 | 51 | // `atoms-<hash>` and one bug re-groups on every release. Pin a |
| 52 | 52 | // fingerprint with the hash normalized out; the event itself keeps its |
| 53 | 53 | // hashed filenames so sourcemap resolution is unaffected. |
| 54 | − beforeSend: (event) => { | |
| 55 | − const fingerprint = stableGroupingFingerprint(event); | |
| 56 | − return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | |
| 57 | − }, | |
| 54 | + beforeSend: withStableGroupingFingerprint, | |
| 58 | 55 | }); |
| 59 | 56 | } catch { |
| 60 | 57 | // Reporting failures stay silent — there is nowhere left to report them. |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | containsContentHash, |
| 5 | 5 | stableGroupingFingerprint, |
| 6 | 6 | stripContentHashes, |
| 7 | + withStableGroupingFingerprint, | |
| 7 | 8 | type GroupingEvent, |
| 8 | 9 | } from "./sentry-grouping"; |
| 9 | 10 | |
| @@ -65,6 +66,30 @@ test("stripContentHashes keeps genuinely different modules distinct", () => { · 55 unmodified lines | ||
| 65 | 66 | ); |
| 66 | 67 | }); |
| 67 | 68 | |
| 69 | +// Merging two unrelated bugs into one issue is unrecoverable, so the "is this | |
| 70 | +// a hash?" threshold is pinned from both sides: a segment needs two of the | |
| 71 | +// three hash signals (uppercase, uppercase+digit, digits) before it is eaten. | |
| 72 | +// Loosening it to one signal — the tempting fix for the word-shaped hashes | |
| 73 | +// this deliberately misses — silently collapses real chunk names. | |
| 74 | +test("a name segment one signal short of a hash is left in place", () => { | |
| 75 | + // One capital, no digits. | |
| 76 | + expect(stripContentHashes("/assets/connections-Settings.js")).toBe( | |
| 77 | + "/assets/connections-Settings.js", | |
| 78 | + ); | |
| 79 | + // One digit, no capitals. | |
| 80 | + expect(stripContentHashes("/assets/storage-s3client.js")).toBe("/assets/storage-s3client.js"); | |
| 81 | + // ...and neither may be mistaken for the chunk it would collapse onto. | |
| 82 | + expect(stripContentHashes("/assets/connections-Settings.js")).not.toBe( | |
| 83 | + stripContentHashes("/assets/connections-BAuwphPA.js"), | |
| 84 | + ); | |
| 85 | +}); | |
| 86 | + | |
| 87 | +test("a segment with two hash signals is eaten", () => { | |
| 88 | + expect(stripContentHashes("/assets/chunk-AbcdefgH.js")).toBe("/assets/chunk.js"); | |
| 89 | + expect(stripContentHashes("/assets/chunk-Abcdefg1.js")).toBe("/assets/chunk.js"); | |
| 90 | + expect(stripContentHashes("/assets/chunk-abcdef12.js")).toBe("/assets/chunk.js"); | |
| 91 | +}); | |
| 92 | + | |
| 68 | 93 | test("containsContentHash detects only hashed paths", () => { |
| 69 | 94 | expect(containsContentHash("/assets/atoms-Yemn7yhP.js")).toBe(true); |
| 70 | 95 | expect(containsContentHash("apps/cloud/src/engine/execution-gate.ts")).toBe(false); |
| @@ -126,27 +151,41 @@ test("minified single-letter frame functions are left out of the fingerprint", ( · 55 unmodified lines | ||
| 126 | 151 | expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual(["TypeError", "atoms"]); |
| 127 | 152 | }); |
| 128 | 153 | |
| 129 | −test("two vendor chunks sharing a name stay apart on their callers", () => { | |
| 130 | − // A single build emits many `dist-<hash>.js` chunks from unrelated packages, | |
| 131 | − // so the top frame alone is not a safe key. | |
| 132 | − const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ | |
| 133 | − exception: { | |
| 134 | − values: [ | |
| 135 | − { | |
| 136 | − type: "TypeError", | |
| 137 | − stacktrace: { | |
| 138 | − frames: [ | |
| 139 | − { module: `${caller}-${hash}`, function: caller, in_app: true }, | |
| 140 | − { module: `dist-${hash}`, function: "x", in_app: false }, | |
| 141 | − ], | |
| 142 | − }, | |
| 154 | +// A single build emits many `dist-<hash>.js` chunks from unrelated packages, | |
| 155 | +// so the crashing frame alone is not a safe key — two different bugs would | |
| 156 | +// land in one issue. Frames are oldest-first, so the shared chunk is last. | |
| 157 | +const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ | |
| 158 | + exception: { | |
| 159 | + values: [ | |
| 160 | + { | |
| 161 | + type: "TypeError", | |
| 162 | + stacktrace: { | |
| 163 | + frames: [ | |
| 164 | + { module: `${caller}-${hash}`, function: caller, in_app: true }, | |
| 165 | + { module: `dist-${hash}`, function: "throwHelper", in_app: true }, | |
| 166 | + ], | |
| 143 | 167 | }, |
| 144 | − ], | |
| 145 | − }, | |
| 146 | − }); | |
| 168 | + }, | |
| 169 | + ], | |
| 170 | + }, | |
| 171 | +}); | |
| 172 | + | |
| 173 | +test("two vendor chunks sharing a name stay apart on their callers", () => { | |
| 174 | + const upload = stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2")); | |
| 175 | + const render = stableGroupingFingerprint(vendorEvent("renderMarkdown", "BQZkXWT2")); | |
| 147 | 176 | |
| 148 | − expect(stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2"))).not.toEqual( | |
| 149 | − stableGroupingFingerprint(vendorEvent("renderMarkdown", "BQZkXWT2")), | |
| 177 | + // The crashing frame is identical in both — only the caller chain separates | |
| 178 | + // them, so a fingerprint built from the top frame alone would over-merge. | |
| 179 | + expect(upload?.slice(0, 2)).toEqual(["TypeError", "throwHelper@dist"]); | |
| 180 | + expect(render?.slice(0, 2)).toEqual(upload?.slice(0, 2)); | |
| 181 | + expect(upload).not.toEqual(render); | |
| 182 | + expect(upload).toEqual(["TypeError", "throwHelper@dist", "uploadArtifact@uploadArtifact"]); | |
| 183 | +}); | |
| 184 | + | |
| 185 | +test("the caller chain is itself deploy-stable", () => { | |
| 186 | + // Every frame in the chain must lose its hash, not just the crashing one. | |
| 187 | + expect(stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2"))).toEqual( | |
| 188 | + stableGroupingFingerprint(vendorEvent("uploadArtifact", "Yemn7yhP")), | |
| 150 | 189 | ); |
| 151 | 190 | }); |
| 152 | 191 | |
| @@ -186,6 +225,35 @@ test("a hashed culprit alone is enough to pin the fingerprint", () => { · 33 unmodified lines | ||
| 186 | 225 | ]); |
| 187 | 226 | }); |
| 188 | 227 | |
| 228 | +// The wrapper every process installs as its `beforeSend`. `fingerprint` and | |
| 229 | +// `tags` stand in for the fields a real Sentry event carries around it. | |
| 230 | +type SentEvent = GroupingEvent & { | |
| 231 | + readonly fingerprint?: readonly string[] | undefined; | |
| 232 | + readonly tags?: Record<string, string> | undefined; | |
| 233 | +}; | |
| 234 | + | |
| 235 | +test("withStableGroupingFingerprint pins the key and changes nothing else", () => { | |
| 236 | + const input: SentEvent = { ...workerEvent("BAuwphPA"), tags: { a: "b" } }; | |
| 237 | + const hashed = withStableGroupingFingerprint(input); | |
| 238 | + expect(hashed.fingerprint).toEqual([ | |
| 239 | + "GateCheckTimeoutError", | |
| 240 | + "timeoutOrElse@execution-rate-limit", | |
| 241 | + ]); | |
| 242 | + // The event still carries its hashed filename, or server-side sourcemap | |
| 243 | + // resolution would stop finding the release's artifacts. | |
| 244 | + expect(hashed.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( | |
| 245 | + "/assets/execution-rate-limit-BAuwphPA.js", | |
| 246 | + ); | |
| 247 | + expect(hashed.tags).toEqual({ a: "b" }); | |
| 248 | +}); | |
| 249 | + | |
| 250 | +test("withStableGroupingFingerprint forwards an unhashed event untouched", () => { | |
| 251 | + const plain: SentEvent = { culprit: "checkExecutionBalance(execution-gate.ts)" }; | |
| 252 | + const out = withStableGroupingFingerprint(plain); | |
| 253 | + expect(out).toBe(plain); | |
| 254 | + expect("fingerprint" in out).toBe(false); | |
| 255 | +}); | |
| 256 | + | |
| 189 | 257 | test("the outermost exception drives the fingerprint", () => { |
| 190 | 258 | // Sentry orders `values` innermost-first; the last entry is the one whose |
| 191 | 259 | // type the issue is titled with. |
| @@ -155,3 +155,15 @@ export const stableGroupingFingerprint = (event: GroupingEvent): readonly string · 154 unmodified lines | ||
| 155 | 155 | if (keys.length > 0) return [type, ...keys]; |
| 156 | 156 | return culprit ? [type, stripContentHashes(culprit)] : undefined; |
| 157 | 157 | }; |
| 158 | + | |
| 159 | +/** | |
| 160 | + * The whole `beforeSend` contract: pin the deploy-stable fingerprint when the | |
| 161 | + * event has a volatile grouping input, and forward the event untouched when it | |
| 162 | + * does not. Every call site (cloud worker/DO, desktop main, desktop renderer) | |
| 163 | + * installs this one symbol rather than its own copy, so the wiring is covered | |
| 164 | + * by the tests below instead of being retyped per process. | |
| 165 | + */ | |
| 166 | +export const withStableGroupingFingerprint = <T extends GroupingEvent>(event: T): T => { | |
| 167 | + const fingerprint = stableGroupingFingerprint(event); | |
| 168 | + return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | |
| 169 | +}; | |