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