History

unwrap effect causes before sentry capture

Rhys Sullivan committed 4 months ago

Commit 73449a91 parent fd4fb023 files · +159 −2Browse files

Unified · Split

3 files
  1. apps/cloud/src/routes/__root.tsx +7 −2
  2. packages/react/src/api/error-reporting.test.ts +78 −0
  3. packages/react/src/api/error-reporting.tsx +74 −0
apps/cloud/src/routes/__root.tsx+7−2
@@ -12,7 +12,10 @@ import { · 11 unmodified lines
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 = · 47 unmodified lines
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+78−0
@@ -1,7 +1,9 @@
1+// oxlint-disable executor/no-error-constructor -- boundary: tests construct plain browser Errors as fixtures for the Sentry capture boundary
12 import { describe, expect, it } from "@effect/vitest";
23 import * as Cause from "effect/Cause";
34 import * as Exit from "effect/Exit";
45 import {
6+ frontendErrorCapturePayload,
57 messageFromExit,
68 messageFromUnknown,
79 reportExitFailure,
@@ -45,4 +47,80 @@ describe("frontend error reporting", () => { · 37 unmodified lines
4547 expect(calls[0]!.context.surface).toBe("sources");
4648 expect(calls[0]!.context.action).toBe("update");
4749 });
50+
51+ it("extracts the failed Error from an Effect Cause", () => {
52+ const original = new Error("GET /api/tools failed with 522");
53+ original.stack = "Error: GET /api/tools failed with 522\n at loadTools (tools.ts:12:3)";
54+ const payload = frontendErrorCapturePayload(Cause.fail(original));
55+
56+ expect(payload.exception).toBe(original);
57+ expect(payload.exception.message).toBe("GET /api/tools failed with 522");
58+ expect(payload.exception.stack).toContain("loadTools");
59+ expect(payload.causePretty).toContain("GET /api/tools failed with 522");
60+ });
61+
62+ it("builds an Error from a string Effect failure", () => {
63+ const payload = frontendErrorCapturePayload(
64+ Cause.fail("GET /api/integrations failed with 522"),
65+ );
66+
67+ expect(payload.exception).toBeInstanceOf(Error);
68+ expect(payload.exception.message).toContain("GET /api/integrations failed with 522");
69+ expect(payload.exception.stack).toContain("GET /api/integrations failed with 522");
70+ expect(payload.causePretty).toContain("GET /api/integrations failed with 522");
71+ });
72+
73+ it("extracts the defect Error from an Effect Cause", () => {
74+ const defect = new TypeError("render crashed");
75+ defect.stack = "TypeError: render crashed\n at renderWidget (widget.tsx:40:5)";
76+ const payload = frontendErrorCapturePayload(Cause.die(defect));
77+
78+ expect(payload.exception).toBe(defect);
79+ expect(payload.exception.message).toBe("render crashed");
80+ expect(payload.exception.stack).toContain("renderWidget");
81+ expect(payload.causePretty).toContain("render crashed");
82+ });
83+
84+ it("unwraps a Cause that arrives wrapped as a defect of another Cause", () => {
85+ // The production chain: runPromise squashes an exit whose defect is itself
86+ // a Cause, so the capture boundary receives Die(CauseImpl).
87+ const original = new Error("GET /api/connections failed with 522");
88+ original.stack =
89+ "Error: GET /api/connections failed with 522\n at loadConnections (connections.ts:9:2)";
90+ const payload = frontendErrorCapturePayload(Cause.die(Cause.fail(original)));
91+
92+ expect(payload.exception).toBeInstanceOf(Error);
93+ expect(payload.exception.message).toContain("GET /api/connections failed with 522");
94+ expect(payload.exception.message).not.toContain("CauseImpl");
95+ expect(payload.causePretty).toContain("GET /api/connections failed with 522");
96+ });
97+
98+ it("unwraps a record carrying a Cause on its cause property", () => {
99+ const original = new Error("wrapped cause failure");
100+ const payload = frontendErrorCapturePayload({ cause: Cause.fail(original) });
101+
102+ expect(payload.exception).toBe(original);
103+ expect(payload.causePretty).toContain("wrapped cause failure");
104+ });
105+
106+ it("passes a plain Error through unchanged", () => {
107+ const original = new Error("plain browser error");
108+ original.stack = "Error: plain browser error\n at onClick (button.tsx:7:1)";
109+ const payload = frontendErrorCapturePayload(original);
110+
111+ expect(payload.exception).toBe(original);
112+ expect(payload.exception.message).toBe("plain browser error");
113+ expect(payload.exception.stack).toContain("onClick");
114+ expect(payload.causePretty).toBeNull();
115+ });
116+
117+ it("builds an Error from a non-Cause non-Error value", () => {
118+ const payload = frontendErrorCapturePayload({ path: "/api/connections", status: 522 });
119+
120+ expect(payload.exception).toBeInstanceOf(Error);
121+ expect(payload.exception.message).toContain("/api/connections");
122+ expect(payload.exception.message).toContain("522");
123+ expect(payload.exception.stack).toContain("Non-Error frontend exception");
124+ expect(payload.causePretty).toBeNull();
125+ });
48126 });
packages/react/src/api/error-reporting.tsx+74−0
@@ -15,14 +15,88 @@ export type FrontendErrorContext = { · 14 unmodified lines
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+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stringify arbitrary unknown values for a Sentry error message; JSON.stringify throws on cycles
53+ try {
54+ const json = JSON.stringify(input);
55+ if (json) return json;
56+ } catch {
57+ return Object.prototype.toString.call(input);
58+ }
59+ return Object.prototype.toString.call(input);
60+};
61+
62+const causeFromUnknown = (input: unknown): Cause.Cause<unknown> | null => {
63+ if (Cause.isCause(input)) return input;
64+ if (isRecord(input) && Cause.isCause(input.cause)) return input.cause;
65+ return null;
66+};
67+
68+const errorFromCause = (cause: Cause.Cause<unknown>, pretty: string): Error => {
69+ const squashed = Cause.squash(cause);
70+ // oxlint-disable-next-line executor/no-instanceof-error -- boundary: deciding whether the squashed failure is already a Sentry-ready Error payload
71+ if (squashed instanceof Error) return squashed;
72+ const prettyError = Cause.prettyErrors(cause)[0];
73+ if (prettyError) return prettyError;
74+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: Sentry needs an Error payload for Effect causes
75+ return new Error(pretty.length > 0 ? pretty : "Effect cause contained no failures");
76+};
77+
78+export const frontendErrorCapturePayload = (input: unknown): FrontendErrorCapturePayload => {
79+ const cause = causeFromUnknown(input);
80+ if (cause !== null) {
81+ const causePretty = truncateCausePretty(Cause.pretty(cause));
82+ return {
83+ exception: errorFromCause(cause, causePretty),
84+ causePretty: causePretty.length > 0 ? causePretty : null,
85+ };
86+ }
87+
88+ // oxlint-disable-next-line executor/no-instanceof-error -- boundary: deciding whether the incoming unknown is already a Sentry-ready Error payload
89+ if (input instanceof Error) {
90+ return { exception: input, causePretty: null };
91+ }
92+
93+ return {
94+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: Sentry needs an Error payload for non-Error frontend failures
95+ exception: new Error(`Non-Error frontend exception: ${formatUnknownForErrorMessage(input)}`),
96+ causePretty: null,
97+ };
98+};
99+
26100 const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {
27101 if (typeof globalThis.reportError !== "function") return;
28102 globalThis.reportError(new FrontendHandledError({ cause: error, context }));