Unified · Split
| @@ -12,7 +12,10 @@ import { · 11 unmodified lines | |||
| 12 | import { AutumnProvider } from "autumn-js/react"; | 12 | import { AutumnProvider } from "autumn-js/react"; |
| 13 | import posthog from "posthog-js"; | 13 | import posthog from "posthog-js"; |
| 14 | import { PostHogProvider } from "posthog-js/react"; | 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 | import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics"; | 19 | import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics"; |
| 17 | import { ExecutorProvider } from "@executor-js/react/api/provider"; | 20 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 18 | import { OrganizationProvider } from "@executor-js/react/api/organization-context"; | 21 | import { 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 | ||
| 68 | const captureFrontendError: FrontendErrorReporter = (error, context) => { | 71 | const 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, |
| @@ -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 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 3 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 4 | import { | 5 | import { |
| 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 | }); |
| @@ -15,14 +15,88 @@ export type FrontendErrorContext = { · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void; | 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 | class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ | 23 | class 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 | ||
| 28 | const MAX_FRONTEND_CAUSE_PRETTY_CHARS = 4_000; | ||
| 29 | |||
| 23 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 30 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 24 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | 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 | const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => { | 100 | const 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 })); |