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
12import { AutumnProvider } from "autumn-js/react";12import { AutumnProvider } from "autumn-js/react";
13import posthog from "posthog-js";13import posthog from "posthog-js";
14import { PostHogProvider } from "posthog-js/react";14import { PostHogProvider } from "posthog-js/react";
15import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";15import {
16 frontendErrorCapturePayload,
17 type FrontendErrorReporter,
18} from "@executor-js/react/api/error-reporting";
16import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics";19import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics";
17import { ExecutorProvider } from "@executor-js/react/api/provider";20import { ExecutorProvider } from "@executor-js/react/api/provider";
18import { OrganizationProvider } from "@executor-js/react/api/organization-context";21import { OrganizationProvider } from "@executor-js/react/api/organization-context";
@@ -66,10 +69,12 @@ const analyticsClient: AnalyticsClient | undefined = · 47 unmodified lines
66 : undefined;69 : undefined;
67 70
68const captureFrontendError: FrontendErrorReporter = (error, context) => {71const captureFrontendError: FrontendErrorReporter = (error, context) => {
69 Sentry.captureException(error, (scope) => {72 const { exception, causePretty } = frontendErrorCapturePayload(error);
73 Sentry.captureException(exception, (scope) => {
70 scope.setTag("executor.ui.surface", context.surface);74 scope.setTag("executor.ui.surface", context.surface);
71 scope.setTag("executor.ui.action", context.action);75 scope.setTag("executor.ui.action", context.action);
72 scope.setTag("executor.ui.severity", context.severity ?? "error");76 scope.setTag("executor.ui.severity", context.severity ?? "error");
77 if (causePretty !== null) scope.setExtra("cause_pretty", causePretty);
73 scope.setContext("executor.ui", {78 scope.setContext("executor.ui", {
74 surface: context.surface,79 surface: context.surface,
75 action: context.action,80 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
1import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
3import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
4import {5import {
6 frontendErrorCapturePayload,
5 messageFromExit,7 messageFromExit,
6 messageFromUnknown,8 messageFromUnknown,
7 reportExitFailure,9 reportExitFailure,
@@ -45,4 +47,80 @@ describe("frontend error reporting", () => { · 37 unmodified lines
45 expect(calls[0]!.context.surface).toBe("sources");47 expect(calls[0]!.context.surface).toBe("sources");
46 expect(calls[0]!.context.action).toBe("update");48 expect(calls[0]!.context.action).toBe("update");
47 });49 });
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 });
48});126});
packages/react/src/api/error-reporting.tsx+74−0
@@ -15,14 +15,88 @@ export type FrontendErrorContext = { · 14 unmodified lines
15 15
16export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;16export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;
17 17
18export type FrontendErrorCapturePayload = {
19 readonly exception: Error;
20 readonly causePretty: string | null;
21};
22
18class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{23class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{
19 readonly cause: unknown;24 readonly cause: unknown;
20 readonly context: FrontendErrorContext;25 readonly context: FrontendErrorContext;
21}> {}26}> {}
22 27
28const MAX_FRONTEND_CAUSE_PRETTY_CHARS = 4_000;
29
23const ErrorMessage = Schema.Struct({ message: Schema.String });30const ErrorMessage = Schema.Struct({ message: Schema.String });
24const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);31const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
25 32
33const isRecord = (input: unknown): input is Record<string, unknown> =>
34 typeof input === "object" && input !== null;
35
36const 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
41const 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
62const 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
68const 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
78export 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
26const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {100const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {
27 if (typeof globalThis.reportError !== "function") return;101 if (typeof globalThis.reportError !== "function") return;
28 globalThis.reportError(new FrontendHandledError({ cause: error, context }));102 globalThis.reportError(new FrontendHandledError({ cause: error, context }));