Skip to content

Commit 545ee73

Browse files
committed
Keep Sentry issue grouping stable across deploys
Normalize build content hashes out of the grouping key in the cloud, desktop main and desktop renderer Sentry inits, and merge address-keyed Chromium soft-assert minidumps into one fingerprint.
1 parent 4c9e755 commit 545ee73

11 files changed

Lines changed: 616 additions & 3 deletions

File tree

apps/cloud/src/observability/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Cause, Effect, Layer } from "effect";
1616
import type * as Tracer from "effect/Tracer";
1717

1818
import { ErrorCapture } from "@executor-js/api";
19+
import { stableGroupingFingerprint, type GroupingEvent } from "@executor-js/sdk/sentry-grouping";
1920

2021
// Drizzle/postgres-js include the failing SQL (params + bound values) in
2122
// their error message. For OpenAPI source inserts that's 1MB+ of spec
@@ -116,6 +117,24 @@ export const beforeSendWithOtelCorrelation = (
116117
return event;
117118
};
118119

120+
/**
121+
* The worker ships as content-hashed chunks and its frames are not resolved
122+
* back to source, so Sentry's default grouping keys on names like
123+
* `execution-rate-limit-<hash>` and re-opens every issue on the next deploy.
124+
* Pin a fingerprint with the hash normalized out; events with no hashed
125+
* grouping input are left on the default algorithm.
126+
*/
127+
export const withStableGroupingFingerprint = <T extends GroupingEvent>(event: T): T => {
128+
const fingerprint = stableGroupingFingerprint(event);
129+
return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event;
130+
};
131+
132+
/** The single `beforeSend` the worker and its Durable Objects install. */
133+
export const beforeSendCloudEvent = (
134+
event: ErrorEvent,
135+
options?: { readonly logPayload?: boolean },
136+
): ErrorEvent => withStableGroupingFingerprint(beforeSendWithOtelCorrelation(event, options));
137+
119138
export const addCurrentOtelCorrelationTags = <
120139
T extends { readonly tags?: Record<string, unknown> },
121140
>(

apps/cloud/src/observability/observability.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { describe, expect, it } from "@effect/vitest";
22
import { Cause, Effect } from "effect";
33
import type * as Tracer from "effect/Tracer";
44

5+
import type { ErrorEvent } from "@sentry/cloudflare";
6+
57
import {
68
addCurrentOtelCorrelationTags,
9+
beforeSendCloudEvent,
710
OTEL_SPAN_ID_TAG,
811
OTEL_TRACE_ID_TAG,
912
sentryPayloadForCause,
@@ -79,6 +82,71 @@ describe("sentryPayloadForCause", () => {
7982
});
8083
});
8184

85+
describe("Sentry grouping", () => {
86+
// The worker bundle ships as content-hashed chunks, so the only module name
87+
// Sentry ever sees for a given frame changes on every deploy.
88+
const workerEvent = (chunkHash: string): ErrorEvent => ({
89+
type: undefined,
90+
exception: {
91+
values: [
92+
{
93+
type: "GateCheckTimeoutError",
94+
value: "balance check timed out",
95+
stacktrace: {
96+
frames: [
97+
{
98+
filename: `/assets/execution-rate-limit-${chunkHash}.js`,
99+
module: `execution-rate-limit-${chunkHash}`,
100+
function: "timeoutOrElse",
101+
in_app: true,
102+
},
103+
],
104+
},
105+
},
106+
],
107+
},
108+
});
109+
110+
it("pins one fingerprint across two deploys of the same chunk", () => {
111+
const before = beforeSendCloudEvent(workerEvent("BAuwphPA"), {});
112+
const after = beforeSendCloudEvent(workerEvent("DkcPBbWe"), {});
113+
114+
expect(before.fingerprint).toBeDefined();
115+
expect(before.fingerprint).toEqual(after.fingerprint);
116+
});
117+
118+
it("leaves unhashed events on Sentry's default grouping", () => {
119+
const event: ErrorEvent = {
120+
type: undefined,
121+
exception: {
122+
values: [
123+
{
124+
type: "AutumnError",
125+
stacktrace: {
126+
frames: [
127+
{ filename: "/src/engine/execution-gate.ts", function: "checkExecutionBalance" },
128+
],
129+
},
130+
},
131+
],
132+
},
133+
};
134+
135+
expect(beforeSendCloudEvent(event, {}).fingerprint).toBeUndefined();
136+
});
137+
138+
it("keeps the OTel correlation tags it is composed with", () => {
139+
const event: ErrorEvent = {
140+
type: undefined,
141+
tags: { [OTEL_TRACE_ID_TAG]: traceId, [OTEL_SPAN_ID_TAG]: spanId },
142+
};
143+
144+
const result = beforeSendCloudEvent(event, {});
145+
expect(result.tags?.[OTEL_TRACE_ID_TAG]).toBe(traceId);
146+
expect(result.tags?.[OTEL_SPAN_ID_TAG]).toBe(spanId);
147+
});
148+
});
149+
82150
describe("Sentry OTel correlation", () => {
83151
it.effect("adds tags from the active Effect span", () =>
84152
Effect.gen(function* () {

apps/cloud/src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
1919
import { parseTraceparent } from "./mcp/traceparent";
2020
import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
2121
import {
22-
beforeSendWithOtelCorrelation,
22+
beforeSendCloudEvent,
2323
captureCause,
2424
otelCorrelationContextFromOpenTelemetrySpan,
2525
SENTRY_EVENT_ID_ATTRIBUTE,
@@ -39,7 +39,7 @@ const sentryOptions = (env: Env) => ({
3939
sendDefaultPii: true,
4040
skipOpenTelemetrySetup: true,
4141
beforeSend: (event: ErrorEvent) =>
42-
beforeSendWithOtelCorrelation(event, {
42+
beforeSendCloudEvent(event, {
4343
logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true",
4444
}),
4545
// NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { expect, test } from "@effect/vitest";
2+
3+
import { crashReportFingerprint, type CrashEvent } from "./crash-fingerprint";
4+
5+
// Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps
6+
// running. Sentry titles each one with the faulting load address, so one
7+
// condition arrives as a new issue every time the address moves.
8+
const softAssertEvent = (address: string): CrashEvent => ({
9+
exception: {
10+
values: [
11+
{
12+
type: "Fatal Error",
13+
value: `Simulated Exception / ${address}`,
14+
mechanism: { type: "minidump" },
15+
stacktrace: {
16+
frames: [
17+
{ function: "logging::NotReachedLogMessage::~NotReachedLogMessage" },
18+
{ function: "logging::HandleCheckErrorLogMessage" },
19+
{ function: "base::debug::DumpWithoutCrashing" },
20+
{ function: "crash_reporter::DumpWithoutCrashing" },
21+
],
22+
},
23+
},
24+
],
25+
},
26+
});
27+
28+
test("address-keyed Chromium soft asserts collapse to one fingerprint", () => {
29+
const first = crashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5"));
30+
const second = crashReportFingerprint(softAssertEvent("0x00000007e8f9a0b1"));
31+
32+
expect(first).toEqual(["chromium-dump-without-crashing"]);
33+
expect(first).toEqual(second);
34+
});
35+
36+
test("a real native abort keeps Sentry's own grouping", () => {
37+
const abortEvent: CrashEvent = {
38+
exception: {
39+
values: [
40+
{
41+
type: "EXC_CRASH",
42+
value: "SIGABRT",
43+
mechanism: { type: "minidump" },
44+
stacktrace: {
45+
frames: [
46+
{ function: "abort" },
47+
{ function: "pthread_kill" },
48+
{ function: "__pthread_kill" },
49+
],
50+
},
51+
},
52+
],
53+
},
54+
};
55+
56+
expect(crashReportFingerprint(abortEvent)).toBeUndefined();
57+
});
58+
59+
test("renderer chunk hashes are normalized out of the fingerprint", () => {
60+
const rendererEvent = (chunkHash: string): CrashEvent => ({
61+
culprit: `loadConnections(assets/atoms-${chunkHash})`,
62+
exception: {
63+
values: [
64+
{
65+
type: "TypeError",
66+
value: "cannot read properties of undefined",
67+
stacktrace: {
68+
frames: [
69+
{
70+
filename: `http://127.0.0.1:4789/assets/atoms-${chunkHash}.js`,
71+
module: `atoms-${chunkHash}`,
72+
function: "loadConnections",
73+
in_app: true,
74+
},
75+
],
76+
},
77+
},
78+
],
79+
},
80+
});
81+
82+
const release1 = crashReportFingerprint(rendererEvent("Yemn7yhP"));
83+
const release2 = crashReportFingerprint(rendererEvent("CeCENfWa"));
84+
85+
expect(release1).toEqual(["TypeError", "loadConnections@atoms"]);
86+
expect(release1).toEqual(release2);
87+
});
88+
89+
test("events with no volatile grouping input are left alone", () => {
90+
expect(crashReportFingerprint({})).toBeUndefined();
91+
expect(
92+
crashReportFingerprint({
93+
exception: {
94+
values: [
95+
{
96+
type: "Error",
97+
stacktrace: {
98+
frames: [{ filename: "/src/main/sidecar.ts", function: "startSidecar" }],
99+
},
100+
},
101+
],
102+
},
103+
}),
104+
).toBeUndefined();
105+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Grouping keys for desktop crash reports.
3+
*
4+
* Two things split one desktop problem across many Sentry issues:
5+
*
6+
* - Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) calls
7+
* `DumpWithoutCrashing`, which files a minidump and lets the process carry
8+
* on. Sentry titles each one with the faulting load address, so the same
9+
* condition arrives as a new issue every time the address moves.
10+
* - Renderer and main bundles ship as content-hashed chunks, so unresolved
11+
* frames name `atoms-<hash>` and re-group on every release.
12+
*
13+
* Both are grouping problems only: nothing here drops, downgrades or edits an
14+
* event, and the frames keep their hashes so sourcemap resolution still works.
15+
*/
16+
17+
import {
18+
stableGroupingFingerprint,
19+
type GroupingEvent,
20+
type GroupingFrame,
21+
} from "@executor-js/sdk/sentry-grouping";
22+
23+
export type CrashEvent = GroupingEvent;
24+
25+
export const CHROMIUM_SOFT_ASSERT_FINGERPRINT = "chromium-dump-without-crashing";
26+
27+
const isSoftAssertFrame = (frame: GroupingFrame): boolean =>
28+
(frame.function ?? "").includes("DumpWithoutCrashing");
29+
30+
/**
31+
* The fingerprint to attach to a crash event, or `undefined` to keep Sentry's
32+
* default grouping (which is right for a real native abort — one issue per
33+
* distinct stack is what we want there).
34+
*/
35+
export const crashReportFingerprint = (event: CrashEvent): readonly string[] | undefined => {
36+
const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []);
37+
if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT];
38+
return stableGroupingFingerprint(event);
39+
};

apps/desktop/src/main/diagnostics.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { dirname, join } from "node:path";
2323
import { app, crashReporter, dialog, shell } from "electron";
2424
import log from "electron-log/main.js";
2525
import * as Sentry from "@sentry/electron/main";
26+
import { crashReportFingerprint } from "./crash-fingerprint";
2627
import { getServerSettings } from "./settings";
2728

2829
const sentryDsn = __EXECUTOR_SENTRY_DSN__;
@@ -89,6 +90,11 @@ export const initErrorReporting = () => {
8990
runId,
9091
},
9192
},
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+
},
9298
});
9399
} else {
94100
// No DSN baked in — keep native crash dumps local so a user-reported

packages/app/src/crash-reporting.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
* once initialized — no reporter rewiring needed.
1313
*/
1414

15+
import { stableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping";
16+
1517
interface CrashReportingConfig {
1618
readonly dsn: string;
1719
readonly release: string;
@@ -45,6 +47,14 @@ export const initDesktopCrashReporting = (): void => {
4547
runId: config.runId,
4648
},
4749
},
50+
// Route chunks are content-hashed, so an unresolved frame names
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
53+
// hashed filenames so sourcemap resolution is unaffected.
54+
beforeSend: (event) => {
55+
const fingerprint = stableGroupingFingerprint(event);
56+
return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event;
57+
},
4858
});
4959
} catch {
5060
// Reporting failures stay silent — there is nowhere left to report them.

packages/core/sdk/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"./testing": "./src/testing.ts",
2626
"./migration": "./src/migration-spec.ts",
2727
"./http-auth": "./src/http-auth/index.ts",
28-
"./public-origin": "./src/public-origin.ts"
28+
"./public-origin": "./src/public-origin.ts",
29+
"./sentry-grouping": "./src/sentry-grouping.ts"
2930
},
3031
"publishConfig": {
3132
"access": "public",
@@ -83,6 +84,12 @@
8384
"types": "./dist/public-origin.d.ts",
8485
"default": "./dist/public-origin.js"
8586
}
87+
},
88+
"./sentry-grouping": {
89+
"import": {
90+
"types": "./dist/sentry-grouping.d.ts",
91+
"default": "./dist/sentry-grouping.js"
92+
}
8693
}
8794
}
8895
},

0 commit comments

Comments
 (0)