Skip to content

Commit 62c5116

Browse files
committed
unwrap effect causes before sentry capture
1 parent fd4fb02 commit 62c5116

3 files changed

Lines changed: 155 additions & 2 deletions

File tree

‎apps/cloud/src/routes/__root.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
import { AutumnProvider } from "autumn-js/react";
1313
import posthog from "posthog-js";
1414
import { PostHogProvider } from "posthog-js/react";
15-
import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
15+
import {
16+
frontendErrorCapturePayload,
17+
type FrontendErrorReporter,
18+
} from "@executor-js/react/api/error-reporting";
1619
import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics";
1720
import { ExecutorProvider } from "@executor-js/react/api/provider";
1821
import { OrganizationProvider } from "@executor-js/react/api/organization-context";
@@ -66,10 +69,12 @@ const analyticsClient: AnalyticsClient | undefined =
6669
: undefined;
6770

6871
const captureFrontendError: FrontendErrorReporter = (error, context) => {
69-
Sentry.captureException(error, (scope) => {
72+
const { exception, causePretty } = frontendErrorCapturePayload(error);
73+
Sentry.captureException(exception, (scope) => {
7074
scope.setTag("executor.ui.surface", context.surface);
7175
scope.setTag("executor.ui.action", context.action);
7276
scope.setTag("executor.ui.severity", context.severity ?? "error");
77+
if (causePretty !== null) scope.setExtra("cause_pretty", causePretty);
7378
scope.setContext("executor.ui", {
7479
surface: context.surface,
7580
action: context.action,

‎packages/react/src/api/error-reporting.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22
import * as Cause from "effect/Cause";
33
import * as Exit from "effect/Exit";
44
import {
5+
frontendErrorCapturePayload,
56
messageFromExit,
67
messageFromUnknown,
78
reportExitFailure,
@@ -45,4 +46,80 @@ describe("frontend error reporting", () => {
4546
expect(calls[0]!.context.surface).toBe("sources");
4647
expect(calls[0]!.context.action).toBe("update");
4748
});
49+
50+
it("extracts the failed Error from an Effect Cause", () => {
51+
const original = new Error("GET /api/tools failed with 522");
52+
original.stack = "Error: GET /api/tools failed with 522\n at loadTools (tools.ts:12:3)";
53+
const payload = frontendErrorCapturePayload(Cause.fail(original));
54+
55+
expect(payload.exception).toBe(original);
56+
expect(payload.exception.message).toBe("GET /api/tools failed with 522");
57+
expect(payload.exception.stack).toContain("loadTools");
58+
expect(payload.causePretty).toContain("GET /api/tools failed with 522");
59+
});
60+
61+
it("builds an Error from a string Effect failure", () => {
62+
const payload = frontendErrorCapturePayload(
63+
Cause.fail("GET /api/integrations failed with 522"),
64+
);
65+
66+
expect(payload.exception).toBeInstanceOf(Error);
67+
expect(payload.exception.message).toContain("GET /api/integrations failed with 522");
68+
expect(payload.exception.stack).toContain("GET /api/integrations failed with 522");
69+
expect(payload.causePretty).toContain("GET /api/integrations failed with 522");
70+
});
71+
72+
it("extracts the defect Error from an Effect Cause", () => {
73+
const defect = new TypeError("render crashed");
74+
defect.stack = "TypeError: render crashed\n at renderWidget (widget.tsx:40:5)";
75+
const payload = frontendErrorCapturePayload(Cause.die(defect));
76+
77+
expect(payload.exception).toBe(defect);
78+
expect(payload.exception.message).toBe("render crashed");
79+
expect(payload.exception.stack).toContain("renderWidget");
80+
expect(payload.causePretty).toContain("render crashed");
81+
});
82+
83+
it("unwraps a Cause that arrives wrapped as a defect of another Cause", () => {
84+
// The production chain: runPromise squashes an exit whose defect is itself
85+
// a Cause, so the capture boundary receives Die(CauseImpl).
86+
const original = new Error("GET /api/connections failed with 522");
87+
original.stack =
88+
"Error: GET /api/connections failed with 522\n at loadConnections (connections.ts:9:2)";
89+
const payload = frontendErrorCapturePayload(Cause.die(Cause.fail(original)));
90+
91+
expect(payload.exception).toBeInstanceOf(Error);
92+
expect(payload.exception.message).toContain("GET /api/connections failed with 522");
93+
expect(payload.exception.message).not.toContain("CauseImpl");
94+
expect(payload.causePretty).toContain("GET /api/connections failed with 522");
95+
});
96+
97+
it("unwraps a record carrying a Cause on its cause property", () => {
98+
const original = new Error("wrapped cause failure");
99+
const payload = frontendErrorCapturePayload({ cause: Cause.fail(original) });
100+
101+
expect(payload.exception).toBe(original);
102+
expect(payload.causePretty).toContain("wrapped cause failure");
103+
});
104+
105+
it("passes a plain Error through unchanged", () => {
106+
const original = new Error("plain browser error");
107+
original.stack = "Error: plain browser error\n at onClick (button.tsx:7:1)";
108+
const payload = frontendErrorCapturePayload(original);
109+
110+
expect(payload.exception).toBe(original);
111+
expect(payload.exception.message).toBe("plain browser error");
112+
expect(payload.exception.stack).toContain("onClick");
113+
expect(payload.causePretty).toBeNull();
114+
});
115+
116+
it("builds an Error from a non-Cause non-Error value", () => {
117+
const payload = frontendErrorCapturePayload({ path: "/api/connections", status: 522 });
118+
119+
expect(payload.exception).toBeInstanceOf(Error);
120+
expect(payload.exception.message).toContain("/api/connections");
121+
expect(payload.exception.message).toContain("522");
122+
expect(payload.exception.stack).toContain("Non-Error frontend exception");
123+
expect(payload.causePretty).toBeNull();
124+
});
48125
});

‎packages/react/src/api/error-reporting.tsx‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,85 @@ export type FrontendErrorContext = {
1515

1616
export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;
1717

18+
export type FrontendErrorCapturePayload = {
19+
readonly exception: Error;
20+
readonly causePretty: string | null;
21+
};
22+
1823
class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{
1924
readonly cause: unknown;
2025
readonly context: FrontendErrorContext;
2126
}> {}
2227

28+
const MAX_FRONTEND_CAUSE_PRETTY_CHARS = 4_000;
29+
2330
const ErrorMessage = Schema.Struct({ message: Schema.String });
2431
const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
2532

33+
const isRecord = (input: unknown): input is Record<string, unknown> =>
34+
typeof input === "object" && input !== null;
35+
36+
const truncateCausePretty = (pretty: string): string =>
37+
pretty.length <= MAX_FRONTEND_CAUSE_PRETTY_CHARS
38+
? pretty
39+
: `${pretty.slice(0, MAX_FRONTEND_CAUSE_PRETTY_CHARS)}\n[truncated ${pretty.length - MAX_FRONTEND_CAUSE_PRETTY_CHARS} chars]`;
40+
41+
const formatUnknownForErrorMessage = (input: unknown): string => {
42+
if (typeof input === "string") return input.length > 0 ? input : "(empty string)";
43+
if (input === null || input === undefined) return String(input);
44+
if (
45+
typeof input === "number" ||
46+
typeof input === "boolean" ||
47+
typeof input === "bigint" ||
48+
typeof input === "symbol"
49+
) {
50+
return String(input);
51+
}
52+
try {
53+
const json = JSON.stringify(input);
54+
if (json) return json;
55+
} catch {
56+
return Object.prototype.toString.call(input);
57+
}
58+
return Object.prototype.toString.call(input);
59+
};
60+
61+
const causeFromUnknown = (input: unknown): Cause.Cause<unknown> | null => {
62+
if (Cause.isCause(input)) return input;
63+
if (isRecord(input) && Cause.isCause(input.cause)) return input.cause;
64+
return null;
65+
};
66+
67+
const errorFromCause = (cause: Cause.Cause<unknown>, pretty: string): Error => {
68+
const squashed = Cause.squash(cause);
69+
if (squashed instanceof Error) return squashed;
70+
const prettyError = Cause.prettyErrors(cause)[0];
71+
if (prettyError) return prettyError;
72+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: Sentry needs an Error payload for Effect causes
73+
return new Error(pretty.length > 0 ? pretty : "Effect cause contained no failures");
74+
};
75+
76+
export const frontendErrorCapturePayload = (input: unknown): FrontendErrorCapturePayload => {
77+
const cause = causeFromUnknown(input);
78+
if (cause !== null) {
79+
const causePretty = truncateCausePretty(Cause.pretty(cause));
80+
return {
81+
exception: errorFromCause(cause, causePretty),
82+
causePretty: causePretty.length > 0 ? causePretty : null,
83+
};
84+
}
85+
86+
if (input instanceof Error) {
87+
return { exception: input, causePretty: null };
88+
}
89+
90+
// oxlint-disable-next-line executor/no-error-constructor -- boundary: Sentry needs an Error payload for non-Error frontend failures
91+
return {
92+
exception: new Error(`Non-Error frontend exception: ${formatUnknownForErrorMessage(input)}`),
93+
causePretty: null,
94+
};
95+
};
96+
2697
const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {
2798
if (typeof globalThis.reportError !== "function") return;
2899
globalThis.reportError(new FrontendHandledError({ cause: error, context }));

0 commit comments

Comments
 (0)