Unified · Split
| @@ -16,7 +16,7 @@ import { Cause, Effect, Layer } from "effect"; · 15 unmodified lines | |||
| 16 | import type * as Tracer from "effect/Tracer"; | 16 | import type * as Tracer from "effect/Tracer"; |
| 17 | 17 | ||
| 18 | import { ErrorCapture } from "@executor-js/api"; | 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 | // Drizzle/postgres-js include the failing SQL (params + bound values) in | 21 | // Drizzle/postgres-js include the failing SQL (params + bound values) in |
| 22 | // their error message. For OpenAPI source inserts that's 1MB+ of spec | 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 | * The worker ships as content-hashed chunks and its frames are not resolved | 153 | * The worker ships as content-hashed chunks and its frames are not resolved |
| 152 | * back to source, so Sentry's default grouping keys on names like | 154 | * back to source, so Sentry's default grouping keys on names like |
| 153 | * `execution-rate-limit-<hash>` and re-opens every issue on the next deploy. | 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 | 156 | * `withStableGroupingFingerprint` pins a key with the hash normalized out; |
| 155 | * grouping input are left on the default algorithm. | 157 | * events with no hashed 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. | ||
| 164 | * | 158 | * |
| 165 | * The two stages are independent and compose in this order: the capture-owner | 159 | * The two stages are independent and compose in this order: the capture-owner |
| 166 | * pass decides WHETHER the event is reported at all (a cause the Durable | 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 | return reported === null ? null : withStableGroupingFingerprint(reported); | 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 | export const addCurrentOtelCorrelationTags = < | 197 | export const addCurrentOtelCorrelationTags = < |
| 180 | T extends { readonly tags?: Record<string, unknown> }, | 198 | T extends { readonly tags?: Record<string, unknown> }, |
| 181 | >( | 199 | >( |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | addCurrentOtelCorrelationTags, | 8 | addCurrentOtelCorrelationTags, |
| 9 | beforeSendCloudEvent, | 9 | beforeSendCloudEvent, |
| 10 | beforeSendWithOtelCorrelation, | 10 | beforeSendWithOtelCorrelation, |
| 11 | cloudSentryOptions, | ||
| 11 | DO_CAUSE_OWNER_TAG, | 12 | DO_CAUSE_OWNER_TAG, |
| 12 | DO_CAUSE_OWNER_VALUE, | 13 | DO_CAUSE_OWNER_VALUE, |
| 13 | OTEL_SPAN_ID_TAG, | 14 | OTEL_SPAN_ID_TAG, |
| @@ -86,8 +87,10 @@ describe("sentryPayloadForCause", () => { · 72 unmodified lines | |||
| 86 | }); | 87 | }); |
| 87 | 88 | ||
| 88 | // Grouping keys are decided inside the Sentry SDK and never appear on any | 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 | // product surface, so the e2e harness cannot observe them. The split is: |
| 90 | // beforeSend itself is covered by e2e/cloud/sentry-otel-correlation.test.ts. | 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 | describe("Sentry grouping", () => { | 94 | describe("Sentry grouping", () => { |
| 92 | // The worker bundle ships as content-hashed chunks, so the only module name | 95 | // The worker bundle ships as content-hashed chunks, so the only module name |
| 93 | // Sentry ever sees for a given frame changes on every deploy. | 96 | // Sentry ever sees for a given frame changes on every deploy. |
| @@ -143,6 +146,21 @@ describe("Sentry grouping", () => { · 49 unmodified lines | |||
| 143 | expect(sent).not.toBeNull(); | 146 | expect(sent).not.toBeNull(); |
| 144 | expect(sent?.fingerprint).toBeUndefined(); | 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 | describe("Sentry OTel correlation", () => { | 166 | describe("Sentry OTel correlation", () => { |
| @@ -1,6 +1,5 @@ | |||
| 1 | import { DurableObject } from "cloudflare:workers"; | 1 | import { DurableObject } from "cloudflare:workers"; |
| 2 | import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; | 2 | import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; |
| 3 | import type { ErrorEvent } from "@sentry/cloudflare"; | ||
| 4 | import { | 3 | import { |
| 5 | ATTR_HTTP_REQUEST_METHOD, | 4 | ATTR_HTTP_REQUEST_METHOD, |
| 6 | ATTR_HTTP_RESPONSE_STATUS_CODE, | 5 | ATTR_HTTP_RESPONSE_STATUS_CODE, |
| @@ -19,7 +18,7 @@ import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; · 12 unmodified lines | |||
| 19 | import { parseTraceparent } from "./mcp/traceparent"; | 18 | import { parseTraceparent } from "./mcp/traceparent"; |
| 20 | import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; | 19 | import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; |
| 21 | import { | 20 | import { |
| 22 | beforeSendCloudEvent, | 21 | cloudSentryOptions, |
| 23 | captureCause, | 22 | captureCause, |
| 24 | otelCorrelationContextFromOpenTelemetrySpan, | 23 | otelCorrelationContextFromOpenTelemetrySpan, |
| 25 | SENTRY_EVENT_ID_ATTRIBUTE, | 24 | SENTRY_EVENT_ID_ATTRIBUTE, |
| @@ -28,28 +27,6 @@ import { · 2 unmodified lines | |||
| 28 | import { browserTracesResponse } from "./observability/browser-traces"; | 27 | import { browserTracesResponse } from "./observability/browser-traces"; |
| 29 | import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; | 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 | // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the | 31 | // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the |
| 55 | // client inside the DO isolate, which plain `Sentry.captureException` cannot | 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 | export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( | 37 | export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( |
| 61 | sentryOptions, | 38 | cloudSentryOptions, |
| 62 | McpSessionDOBase, | 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 | import { expect, test } from "@effect/vitest"; | 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 | // Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps | 13 | // Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps |
| 6 | // running. Sentry titles each one with the faulting load address, so one | 14 | // running. Sentry titles each one with the faulting load address, so one |
| 7 | // condition arrives as a new issue every time the address moves. | 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 | exception: { | 17 | exception: { |
| 10 | values: [ | 18 | values: [ |
| 11 | { | 19 | { |
| @@ -86,6 +94,21 @@ test("renderer chunk hashes are normalized out of the fingerprint", () => { · 74 unmodified lines | |||
| 86 | expect(release1).toEqual(release2); | 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 | test("events with no volatile grouping input are left alone", () => { | 112 | test("events with no volatile grouping input are left alone", () => { |
| 90 | expect(crashReportFingerprint({})).toBeUndefined(); | 113 | expect(crashReportFingerprint({})).toBeUndefined(); |
| 91 | expect( | 114 | expect( |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import { | 17 | import { |
| 18 | stableGroupingFingerprint, | 18 | stableGroupingFingerprint, |
| 19 | withStableGroupingFingerprint, | ||
| 19 | type GroupingEvent, | 20 | type GroupingEvent, |
| 20 | type GroupingFrame, | 21 | type GroupingFrame, |
| 21 | } from "@executor-js/sdk/sentry-grouping"; | 22 | } from "@executor-js/sdk/sentry-grouping"; |
| @@ -37,3 +38,15 @@ export const crashReportFingerprint = (event: CrashEvent): readonly string[] | u · 15 unmodified lines | |||
| 37 | if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT]; | 38 | if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT]; |
| 38 | return stableGroupingFingerprint(event); | 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 | import { app, crashReporter, dialog, shell } from "electron"; | 23 | import { app, crashReporter, dialog, shell } from "electron"; |
| 24 | import log from "electron-log/main.js"; | 24 | import log from "electron-log/main.js"; |
| 25 | import * as Sentry from "@sentry/electron/main"; | 25 | import * as Sentry from "@sentry/electron/main"; |
| 26 | import { crashReportFingerprint } from "./crash-fingerprint"; | 26 | import { withCrashReportFingerprint } from "./crash-fingerprint"; |
| 27 | import { getServerSettings } from "./settings"; | 27 | import { getServerSettings } from "./settings"; |
| 28 | 28 | ||
| 29 | const sentryDsn = __EXECUTOR_SENTRY_DSN__; | 29 | const sentryDsn = __EXECUTOR_SENTRY_DSN__; |
| @@ -91,10 +91,7 @@ export const initErrorReporting = () => { · 61 unmodified lines | |||
| 91 | }, | 91 | }, |
| 92 | }, | 92 | }, |
| 93 | // Grouping only — the event is forwarded untouched otherwise. | 93 | // Grouping only — the event is forwarded untouched otherwise. |
| 94 | beforeSend: (event) => { | 94 | beforeSend: withCrashReportFingerprint, |
| 95 | const fingerprint = crashReportFingerprint(event); | ||
| 96 | return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | ||
| 97 | }, | ||
| 98 | }); | 95 | }); |
| 99 | } else { | 96 | } else { |
| 100 | // No DSN baked in — keep native crash dumps local so a user-reported | 97 | // No DSN baked in — keep native crash dumps local so a user-reported |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | |||
| 12 | * once initialized — no reporter rewiring needed. | 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 | interface CrashReportingConfig { | 17 | interface CrashReportingConfig { |
| 18 | readonly dsn: string; | 18 | readonly dsn: string; |
| @@ -51,10 +51,7 @@ export const initDesktopCrashReporting = (): void => { · 32 unmodified lines | |||
| 51 | // `atoms-<hash>` and one bug re-groups on every release. Pin a | 51 | // `atoms-<hash>` and one bug re-groups on every release. Pin a |
| 52 | // fingerprint with the hash normalized out; the event itself keeps its | 52 | // fingerprint with the hash normalized out; the event itself keeps its |
| 53 | // hashed filenames so sourcemap resolution is unaffected. | 53 | // hashed filenames so sourcemap resolution is unaffected. |
| 54 | beforeSend: (event) => { | 54 | beforeSend: withStableGroupingFingerprint, |
| 55 | const fingerprint = stableGroupingFingerprint(event); | ||
| 56 | return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; | ||
| 57 | }, | ||
| 58 | }); | 55 | }); |
| 59 | } catch { | 56 | } catch { |
| 60 | // Reporting failures stay silent — there is nowhere left to report them. | 57 | // Reporting failures stay silent — there is nowhere left to report them. |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | containsContentHash, | 4 | containsContentHash, |
| 5 | stableGroupingFingerprint, | 5 | stableGroupingFingerprint, |
| 6 | stripContentHashes, | 6 | stripContentHashes, |
| 7 | withStableGroupingFingerprint, | ||
| 7 | type GroupingEvent, | 8 | type GroupingEvent, |
| 8 | } from "./sentry-grouping"; | 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 | test("containsContentHash detects only hashed paths", () => { | 93 | test("containsContentHash detects only hashed paths", () => { |
| 69 | expect(containsContentHash("/assets/atoms-Yemn7yhP.js")).toBe(true); | 94 | expect(containsContentHash("/assets/atoms-Yemn7yhP.js")).toBe(true); |
| 70 | expect(containsContentHash("apps/cloud/src/engine/execution-gate.ts")).toBe(false); | 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 | expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual(["TypeError", "atoms"]); | 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", () => { | 154 | // A single build emits many `dist-<hash>.js` chunks from unrelated packages, |
| 130 | // 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 |
| 131 | // so the top frame alone is not a safe key. | 156 | // land in one issue. Frames are oldest-first, so the shared chunk is last. |
| 132 | const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ | 157 | const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ |
| 133 | exception: { | 158 | exception: { |
| 134 | values: [ | 159 | values: [ |
| 135 | { | 160 | { |
| 136 | type: "TypeError", | 161 | type: "TypeError", |
| 137 | stacktrace: { | 162 | stacktrace: { |
| 138 | frames: [ | 163 | frames: [ |
| 139 | { module: `${caller}-${hash}`, function: caller, in_app: true }, | 164 | { module: `${caller}-${hash}`, function: caller, in_app: true }, |
| 140 | { module: `dist-${hash}`, function: "x", in_app: false }, | 165 | { module: `dist-${hash}`, function: "throwHelper", in_app: true }, |
| 141 | ], | 166 | ], |
| 142 | }, | ||
| 143 | }, | 167 | }, |
| 144 | ], | 168 | }, |
| 145 | }, | 169 | ], |
| 146 | }); | 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( | 177 | // The crashing frame is identical in both — only the caller chain separates |
| 149 | stableGroupingFingerprint(vendorEvent("renderMarkdown", "BQZkXWT2")), | 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 | test("the outermost exception drives the fingerprint", () => { | 257 | test("the outermost exception drives the fingerprint", () => { |
| 190 | // Sentry orders `values` innermost-first; the last entry is the one whose | 258 | // Sentry orders `values` innermost-first; the last entry is the one whose |
| 191 | // type the issue is titled with. | 259 | // type the issue is titled with. |
| @@ -155,3 +155,15 @@ export const stableGroupingFingerprint = (event: GroupingEvent): readonly string · 154 unmodified lines | |||
| 155 | if (keys.length > 0) return [type, ...keys]; | 155 | if (keys.length > 0) return [type, ...keys]; |
| 156 | return culprit ? [type, stripContentHashes(culprit)] : undefined; | 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 | }; | ||