diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 517d53b0b..ab0eb5406 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -13,7 +13,7 @@ import { AutumnProvider } from "autumn-js/react"; import { isValidOrgSlug } from "@executor-js/api"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; -import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; +import { createSentryFrontendErrorReporter } from "@executor-js/react/api/error-reporting"; import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; @@ -73,20 +73,15 @@ const analyticsClient: AnalyticsClient | undefined = ? (name, properties) => posthog.capture(name, properties) : undefined; -const captureFrontendError: FrontendErrorReporter = (error, context) => { +// Shared with the desktop renderer: the factory normalizes the reported value +// to a real Error (handed an Effect Cause, Sentry has no message to title or +// group on) and owns the executor.ui tags. Only the transport differs. +const captureFrontendError = createSentryFrontendErrorReporter((error, applyScope) => { Sentry.captureException(error, (scope) => { - scope.setTag("executor.ui.surface", context.surface); - scope.setTag("executor.ui.action", context.action); - scope.setTag("executor.ui.severity", context.severity ?? "error"); - scope.setContext("executor.ui", { - surface: context.surface, - action: context.action, - message: context.message, - metadata: context.metadata, - }); + applyScope(scope); return scope; }); -}; +}); function NotFoundPage() { return ( diff --git a/e2e/cloud/frontend-error-reporting.test.ts b/e2e/cloud/frontend-error-reporting.test.ts new file mode 100644 index 000000000..cfaa3d19e --- /dev/null +++ b/e2e/cloud/frontend-error-reporting.test.ts @@ -0,0 +1,132 @@ +// Cloud (browser): a failed API request is reported as a titled error. +// +// The console reports handled UI failures to the crash reporter, and every +// producer of one starts from an Effect `Cause` — a plain object with no name, +// message or stack. Handed that directly, the reporter has nothing to title +// the report with, so it files a message-less one and groups it on the +// reporting frame: unrelated frontend failures all land in a single nameless +// bucket that says only which function did the reporting, never what broke. +// +// The report is the product surface here, so this scenario reads it the way +// the outside world does. The browser SDK is configured to POST its envelopes +// same-origin (`tunnel`), so the suite intercepts that request and asserts on +// the payload the page actually tried to send. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { revisit, visit } from "../src/surfaces/browser"; + +type ReportedException = { + readonly type?: string; + readonly value?: string; + // The reporter marks an exception `synthetic` when it was handed something + // that was not a real error and had to invent a stack for it — the stack of + // whatever frame did the reporting. + readonly mechanism?: { readonly synthetic?: boolean }; +}; + +type ReportedEvent = { + readonly tags?: Record; + readonly exception?: { readonly values?: ReadonlyArray }; +}; + +/** + * An envelope is newline-delimited JSON — a header, then `{type}` / payload + * pairs. Only the error payloads matter here and they are the ones carrying + * `exception`, so pick those out rather than modelling the whole format. + */ +const errorEventsIn = (body: string): ReadonlyArray => + body + .split("\n") + .filter((line) => line.trim().startsWith("{")) + .flatMap((line) => { + try { + const parsed = JSON.parse(line) as ReportedEvent; + return parsed.exception ? [parsed] : []; + } catch { + return []; + } + }); + +scenario( + "Frontend errors · a failed API request is reported with a real message", + { timeout: 120_000 }, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const reports: Array = []; + await page.route("**/api/sentry-tunnel*", async (route) => { + reports.push(...errorEventsIn(route.request().postData() ?? "")); + await route.fulfill({ status: 200, body: "" }); + }); + + // Everything the UI reports about a request it made itself. + const reportedFailures = (): ReadonlyArray => + reports + .filter((event) => event.tags?.["executor.ui.surface"] === "api_client") + .flatMap((event) => event.exception?.values ?? []); + + await step("Open the integrations console", async () => { + await visit(page, "/integrations"); + await page.getByRole("button", { name: "Connect" }).first().waitFor(); + }); + + let faulted = 0; + await step("Reload it with the integrations API failing", async () => { + await page.route("**/api/integrations", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + faulted += 1; + await route.fulfill({ + status: 500, + contentType: "text/plain", + body: "upstream exploded", + }); + }); + await revisit(page); + }); + + expect(faulted, "the integrations request really did fail").toBeGreaterThan(0); + + // The report must name the failure. Reports are sent as the page + // notices failures, so wait for one rather than sleeping. + await expect + .poll(() => reportedFailures().map((failure) => failure.value ?? ""), { + message: "the reported failure says which request failed, and how", + timeout: 20_000, + }) + .toContainEqual(expect.stringMatching(/500 .*\/api\/integrations/)); + + for (const failure of reportedFailures()) { + // A report with no message is the bug: it cannot be titled, so it + // groups on the reporting frame and swallows every other failure. + expect(failure.value ?? "", "every report carries a message").not.toBe(""); + expect(failure.type ?? "", "every report carries an error name").not.toBe(""); + // What a reporter falls back to when it is handed something that is + // not an error at all — the shape every message-less report had. + expect(failure.value ?? "", "no report is a bag of keys").not.toMatch( + /captured as exception with keys/, + ); + // The other half of the bug, and the half a readable message can hide: + // handed a non-error, the reporter still has no stack of its own to + // group on and invents one from the reporting frame, so unrelated + // failures keep merging into a single bucket. Only a real error clears + // this flag. + expect( + failure.mechanism?.synthetic ?? false, + "the report carries the failure's own stack, not the reporter's frame", + ).toBe(false); + } + + await page.unroute("**/api/integrations"); + await page.unroute("**/api/sentry-tunnel*"); + }); + }), +); diff --git a/e2e/setup/cloud.globalsetup.ts b/e2e/setup/cloud.globalsetup.ts index 98433ca83..2867e5f90 100644 --- a/e2e/setup/cloud.globalsetup.ts +++ b/e2e/setup/cloud.globalsetup.ts @@ -32,8 +32,21 @@ const optionalCloudEnv = (): Record => { const env: Record = { SENTRY_OTEL_VERIFY: "true", SENTRY_OTEL_LOG_PAYLOAD: "true", + // Boot the BROWSER crash reporter too, so what the frontend actually + // reports is observable to a scenario. Production always has this set; + // without it the reporter the app wires into ExecutorProvider is a no-op + // and "the frontend reports nothing at all" looks identical to health. + // Nothing leaves the machine: the SDK is configured with + // `tunnel: "/api/sentry-tunnel"`, so envelopes are POSTed same-origin, + // and that route 204s unless a server-side SENTRY_DSN is configured. + VITE_PUBLIC_SENTRY_DSN: "https://e2epublickey@ingest.e2e.invalid/1", }; - for (const key of ["SENTRY_DSN", "SENTRY_OTEL_LOG_PAYLOAD", "SENTRY_OTEL_VERIFY"]) { + for (const key of [ + "SENTRY_DSN", + "SENTRY_OTEL_LOG_PAYLOAD", + "SENTRY_OTEL_VERIFY", + "VITE_PUBLIC_SENTRY_DSN", + ]) { const value = process.env[key]; if (value) env[key] = value; } diff --git a/packages/app/package.json b/packages/app/package.json index b8936d7e6..6c1efde0f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,6 +14,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsgo --noEmit", "typecheck:slow": "tsc --noEmit", "routes:gen": "bun scripts/gen-routes.ts" diff --git a/packages/app/src/crash-reporting.test.ts b/packages/app/src/crash-reporting.test.ts new file mode 100644 index 000000000..014788e45 --- /dev/null +++ b/packages/app/src/crash-reporting.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve +import { vi } from "vitest"; +import * as Cause from "effect/Cause"; + +// oxlint-disable-next-line executor/no-error-constructor, executor/no-redundant-error-factory -- boundary: fixture for the adapter that turns raw thrown values into crash-report Errors +const rawError = (message: string): Error => new Error(message); + +type Scope = { + readonly setTag: (key: string, value: string) => unknown; + readonly setContext: (key: string, value: Record | null) => unknown; +}; + +const captured: Array<{ + readonly error: unknown; + readonly tags: Record; + readonly contexts: Record | null>; +}> = []; + +vi.mock("@sentry/browser", () => ({ + init: () => {}, + captureException: (error: unknown, configure: (scope: Scope) => Scope) => { + const tags: Record = {}; + const contexts: Record | null> = {}; + configure({ + setTag: (key, value) => (tags[key] = value), + setContext: (key, value) => (contexts[key] = value), + }); + captured.push({ error, tags, contexts }); + return "event-id"; + }, +})); + +const desktopBridge = { + executor: { + getCrashReporting: async () => ({ + dsn: "https://public@sentry.invalid/1", + release: "0.0.0-test", + environment: "test", + runId: "run-test", + }), + }, +}; + +const uiContext = { surface: "integrations", action: "connect" } as const; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + captured.length = 0; +}); + +/** + * The renderer used to pass no reporter at all, so handled UI errors fell + * through to `globalThis.reportError`. Sentry's global `onerror` integration + * then filed them as UNHANDLED crashes, titled from a `Data.TaggedError` with + * no `message` field — `FrontendHandledError: No error message` — with the + * surface/action context dropped entirely. + */ +describe("renderer handled-error reporting", () => { + it("falls back to a global error event before Sentry is initialized", async () => { + const reported: Array = []; + vi.stubGlobal("reportError", (error: unknown) => { + reported.push(error); + }); + + const { reportRendererHandledError } = await import("./crash-reporting"); + reportRendererHandledError(Cause.die(rawError("connect failed")), uiContext); + + expect(reported).toHaveLength(1); + // Nothing is lost in builds that never get a DSN: the fallback still + // carries the failure text rather than an empty message. + expect((reported[0] as Error).message).toContain("connect failed"); + }); + + it("captures handled errors through Sentry once the desktop bridge supplies a DSN", async () => { + vi.stubGlobal("window", desktopBridge); + + const module = await import("./crash-reporting"); + module.initDesktopCrashReporting(); + + await vi.waitFor(() => { + module.reportRendererHandledError(Cause.die(rawError("connect failed")), uiContext); + expect(captured.length).toBeGreaterThan(0); + }); + + const event = captured[0]!; + // A real Error, so the event has a title and groups on the failure rather + // than on whichever minified frame reported it. + expect(event.error).toBeInstanceOf(Error); + expect((event.error as Error).message).toBe("connect failed"); + expect(event.tags["executor.ui.surface"]).toBe("integrations"); + expect(event.tags["executor.ui.action"]).toBe("connect"); + expect(event.contexts["executor.ui"]).toMatchObject({ + surface: "integrations", + action: "connect", + }); + }); + + it("stays on the fallback when the desktop bridge is absent", async () => { + vi.stubGlobal("window", {}); + const reported: Array = []; + vi.stubGlobal("reportError", (error: unknown) => { + reported.push(error); + }); + + const module = await import("./crash-reporting"); + module.initDesktopCrashReporting(); + module.reportRendererHandledError("plain failure", uiContext); + + expect(captured).toHaveLength(0); + expect(reported).toHaveLength(1); + expect((reported[0] as Error).message).toContain("plain failure"); + }); +}); diff --git a/packages/app/src/crash-reporting.ts b/packages/app/src/crash-reporting.ts index e06146aa7..7102ef70a 100644 --- a/packages/app/src/crash-reporting.ts +++ b/packages/app/src/crash-reporting.ts @@ -7,10 +7,19 @@ * everywhere else the bridge is absent (or returns null in DSN-less builds) * and Sentry is never imported, let alone initialized. * - * Handled UI errors already flow through `globalThis.reportError` (see - * packages/react error-reporting), which Sentry's global handlers pick up - * once initialized — no reporter rewiring needed. + * Handled UI errors are reported through `reportRendererHandledError`, which + * the root route hands to `ExecutorProvider`. Letting them fall through to + * `globalThis.reportError` instead — as this module used to — filed them via + * Sentry's global `onerror` handler, i.e. as UNHANDLED crashes, with the + * surface/action context dropped on the floor. Until the DSN arrives (and in + * every build without one) the global-event fallback still applies, so nothing + * is lost in self-host. */ +import { + createSentryFrontendErrorReporter, + reportViaGlobalErrorEvent, + type FrontendErrorReporter, +} from "@executor-js/react/api/error-reporting"; interface CrashReportingConfig { readonly dsn: string; @@ -23,6 +32,16 @@ interface CrashReportingBridge { readonly getCrashReporting?: () => Promise; } +let initializedReporter: FrontendErrorReporter | null = null; + +/** + * The reporter the renderer's `ExecutorProvider` uses. Stable identity, so it + * can be passed as a prop, and safe to call before (or without) Sentry init. + */ +export const reportRendererHandledError: FrontendErrorReporter = (error, context) => { + (initializedReporter ?? reportViaGlobalErrorEvent)(error, context); +}; + export const initDesktopCrashReporting = (): void => { if (typeof window === "undefined") return; const bridge = (window as Window & { readonly executor?: CrashReportingBridge }).executor; @@ -46,6 +65,12 @@ export const initDesktopCrashReporting = (): void => { }, }, }); + initializedReporter = createSentryFrontendErrorReporter((error, applyScope) => { + Sentry.captureException(error, (scope) => { + applyScope(scope); + return scope; + }); + }); } catch { // Reporting failures stay silent — there is nowhere left to report them. } diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index 348023535..7236c1b94 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -5,6 +5,7 @@ import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { Toaster } from "@executor-js/react/components/sonner"; import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; +import { reportRendererHandledError } from "../crash-reporting"; import { Shell } from "../web/shell"; // The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which @@ -41,7 +42,7 @@ function NotFoundPage() { function RootComponent() { return ( - + diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts new file mode 100644 index 000000000..4dbb73b6c --- /dev/null +++ b/packages/app/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + passWithNoTests: true, + }, +}); diff --git a/packages/react/src/api/error-reporting.test.ts b/packages/react/src/api/error-reporting.test.ts index bc8841721..655f792ba 100644 --- a/packages/react/src/api/error-reporting.test.ts +++ b/packages/react/src/api/error-reporting.test.ts @@ -1,13 +1,42 @@ +// That a handled UI failure reaches the crash reporter as a titled error is +// covered end to end by e2e/cloud/frontend-error-reporting.test.ts, which reads +// the report off the wire. What is left here is the input shapes a browser run +// cannot produce on demand — a thrown string, a tagged error declared without a +// `message` field, an already-normalized error — at the pure function that has +// to handle each of them. import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; import * as Exit from "effect/Exit"; import { messageFromExit, messageFromUnknown, reportExitFailure, + toReportableError, type FrontendErrorContext, } from "./error-reporting"; +const context: FrontendErrorContext = { + surface: "api_client", + action: "decode_or_transport", +}; + +/** + * These tests exercise the adapter between Effect's failure model and a crash + * reporter, so a built-in `Error` is the fixture the adapter exists to accept + * and to produce. + */ +// oxlint-disable-next-line executor/no-error-constructor, executor/no-redundant-error-factory -- boundary: fixture for the raw-Error adapter under test +const rawError = (message: string): Error => new Error(message); + +const captureReports = (): { + readonly calls: Array<{ error: unknown; context: FrontendErrorContext }>; + readonly report: (error: unknown, context: FrontendErrorContext) => void; +} => { + const calls: Array<{ error: unknown; context: FrontendErrorContext }> = []; + return { calls, report: (error, ctx) => calls.push({ error, context: ctx }) }; +}; + describe("frontend error reporting", () => { it("extracts stable messages from structured failures", () => { expect(messageFromUnknown({ message: "Saved connection failed" }, "Fallback")).toBe( @@ -26,23 +55,83 @@ describe("frontend error reporting", () => { it("reports failed exits with the provided context", () => { const exit = Exit.fail({ message: "Could not update integration" }); - const calls: Array<{ error: unknown; context: FrontendErrorContext }> = []; - - reportExitFailure( - (error, context) => { - calls.push({ error, context }); - }, - exit, - { - surface: "integrations", - action: "update", - message: "Could not update integration", - }, - ); + const { calls, report } = captureReports(); + + reportExitFailure(report, exit, { + surface: "integrations", + action: "update", + message: "Could not update integration", + }); expect(calls).toHaveLength(1); - expect(Cause.isCause(calls[0]!.error)).toBe(true); expect(calls[0]!.context.surface).toBe("integrations"); expect(calls[0]!.context.action).toBe("update"); + // The originating cause travels with the report rather than being reported + // as-is — see the normalization contract below. + expect(Cause.isCause((calls[0]!.error as Error).cause)).toBe(true); + }); +}); + +class ConnectionRefused extends Data.TaggedError("ConnectionRefused")<{ + readonly endpoint: string; +}> {} + +describe("toReportableError", () => { + it("returns a real Error untouched", () => { + const original = rawError("original failure"); + + const reportable = toReportableError(original, context); + + expect(reportable).toBe(original); + expect(reportable.message).toBe("original failure"); + expect(reportable.stack).toBe(original.stack); + }); + + it("keeps the message and stack of a defect inside a cause", () => { + const defect = rawError("render crashed"); + + const reportable = toReportableError(Cause.die(defect), context); + + expect(reportable).toBeInstanceOf(Error); + expect(reportable.message).toBe("render crashed"); + expect(reportable.stack ?? "").toContain("render crashed"); + expect(Cause.isCause(reportable.cause)).toBe(true); + }); + + it("titles a thrown string", () => { + const reportable = toReportableError("something went sideways", context); + + expect(reportable).toBeInstanceOf(Error); + expect(reportable.message).toBe("something went sideways"); + }); + + it("titles a cause carrying a thrown string", () => { + const reportable = toReportableError(Cause.fail("upstream said no"), context); + + expect(reportable.message).toBe("upstream said no"); + }); + + it("names an Effect tagged error by its tag and never leaves it message-less", () => { + const reportable = toReportableError( + Cause.fail(new ConnectionRefused({ endpoint: "https://api.example.test" })), + { ...context, message: "Could not reach the API" }, + ); + + // A `Data.TaggedError` declared without a `message` field renders with an + // empty one — the exact shape that produced `No error message` in Sentry. + expect(reportable.name).toBe("ConnectionRefused"); + expect(reportable.message).toBe("Could not reach the API"); + }); + + it("falls back to the reporting surface when nothing carries a message", () => { + const reportable = toReportableError({ status: 418 }, context); + + expect(reportable.message).toBe("api_client/decode_or_transport"); + }); + + it("is idempotent, so layering it is safe", () => { + const once = toReportableError(Cause.die(rawError("render crashed")), context); + + expect(toReportableError(once, context)).toBe(once); }); }); diff --git a/packages/react/src/api/error-reporting.tsx b/packages/react/src/api/error-reporting.tsx index 6b5ec32f6..8a9eab0d0 100644 --- a/packages/react/src/api/error-reporting.tsx +++ b/packages/react/src/api/error-reporting.tsx @@ -16,6 +16,7 @@ export type FrontendErrorContext = { export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void; class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ + readonly message: string; readonly cause: unknown; readonly context: FrontendErrorContext; }> {} @@ -23,16 +24,157 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); -const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => { +const TaggedValue = Schema.Struct({ _tag: Schema.String }); +const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue); + +export const messageFromUnknown = (error: unknown, fallback: string): string => + Option.match(decodeErrorMessage(error), { + onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback), + onSome: ({ message }) => message, + }); + +// --------------------------------------------------------------------------- +// Normalization +// +// Crash reporters (Sentry in cloud and in the desktop renderer) title and group +// an event from the reported value's `name`, `message` and `stack`. Every +// producer in this codebase starts from an Effect `Cause`, which is a plain +// object with none of the three: Sentry then synthesizes `'CauseImpl' captured +// as exception with keys: ...` and, having no message to group on, groups on +// the reporting function's own stack frame — collapsing unrelated frontend +// failures into a single message-less issue. +// +// `toReportableError` is the choke point that turns whatever a call site has +// into a real `Error` that still says what went wrong. It is idempotent: an +// `Error` in, the same `Error` out, so applying it at more than one layer is +// safe. +// --------------------------------------------------------------------------- + +const contextLabel = (context: FrontendErrorContext): string => + `${context.surface}/${context.action}`; + +const hasText = (value: string | undefined): value is string => + typeof value === "string" && value.trim().length > 0; + +/** The best sentence available for a value that carries no usable message. */ +const describeUnknown = (value: unknown, context: FrontendErrorContext): string => + messageFromUnknown(value, context.message ?? contextLabel(context)); + +/** Effect's tagged errors identify themselves by `_tag`, not by `name`. */ +const nameFromUnknown = (value: unknown, fallback: string): string => + Option.match(decodeTaggedValue(value), { + onNone: () => fallback, + onSome: ({ _tag }) => _tag, + }); + +/** Keep the original value reachable without clobbering an existing chain. */ +const withOriginalCause = (error: Error, original: unknown): Error => { + if (error.cause === undefined && original !== error) error.cause = original; + return error; +}; + +const errorFromValue = (value: unknown, context: FrontendErrorContext): Error => { + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors; this module is the adapter that produces them + const error = new Error(describeUnknown(value, context)); + error.name = nameFromUnknown(value, context.surface); + return withOriginalCause(error, value); +}; + +const errorFromCause = (cause: Cause.Cause, context: FrontendErrorContext): Error => { + // `prettyErrors` is the non-lossy conversion: it renders every reason — + // failures AND defects — as a freshly built `Error` that keeps the original's + // name, message and stack frames. + const [rendered] = Cause.prettyErrors(cause); + if (rendered === undefined) return errorFromValue(Cause.squash(cause), context); + if (!hasText(rendered.message)) { + // A tagged error declared without a `message` field renders with an empty + // one — that is what produced `FrontendHandledError: No error message`. + // The rendered Error is ours, built just above, so filling it in cannot + // mutate a value a call site still holds, and assigning (rather than + // rebuilding) keeps the captured stack frames intact. + rendered.message = describeUnknown(Cause.squash(cause), context); + } + return withOriginalCause(rendered, cause); +}; + +export const toReportableError = (error: unknown, context: FrontendErrorContext): Error => { + if (Cause.isCause(error)) return errorFromCause(error, context); + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: the report is already whatever a call site threw; deciding whether the transport can consume it as-is is this adapter's whole job + if (error instanceof Error) { + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; an empty message is exactly the case being repaired + if (hasText(error.message)) return error; + // Never mutate an Error a call site owns: rebuild it, carrying the stack. + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a crash-reporting transport only understands built-in Errors + const titled = new Error(describeUnknown(error, context)); + titled.name = error.name; + if (typeof error.stack === "string") titled.stack = error.stack; + return withOriginalCause(titled, error); + } + return errorFromValue(error, context); +}; + +/** Wraps a reporter so it can only ever be handed a titled `Error`. */ +const normalizing = + (reporter: FrontendErrorReporter): FrontendErrorReporter => + (error, context) => { + reporter(toReportableError(error, context), context); + }; + +// --------------------------------------------------------------------------- +// Reporters +// --------------------------------------------------------------------------- + +/** + * The fallback transport: re-throw the report as a global `error` event, which + * an initialized crash reporter's global handlers pick up. Used by self-host + * builds and by the desktop renderer before its Sentry client is ready. + */ +export const reportViaGlobalErrorEvent: FrontendErrorReporter = (error, context) => { if (typeof globalThis.reportError !== "function") return; - globalThis.reportError(new FrontendHandledError({ cause: error, context })); + const reportable = toReportableError(error, context); + globalThis.reportError( + new FrontendHandledError({ + message: `${contextLabel(context)}: ${reportable.message}`, + cause: reportable, + context, + }), + ); +}; + +export type FrontendErrorScope = { + readonly setTag: (key: string, value: string) => unknown; + readonly setContext: (key: string, value: Record | null) => unknown; }; +/** + * The one Sentry-shaped reporter both product shells use. `captureException` + * stays a parameter so this module never depends on a Sentry package — cloud + * passes `@sentry/react`'s, the desktop renderer passes the lazily imported + * `@sentry/browser` one. + */ +export const createSentryFrontendErrorReporter = + ( + captureException: (error: Error, applyScope: (scope: FrontendErrorScope) => void) => void, + ): FrontendErrorReporter => + (error, context) => { + captureException(toReportableError(error, context), (scope) => { + scope.setTag("executor.ui.surface", context.surface); + scope.setTag("executor.ui.action", context.action); + scope.setTag("executor.ui.severity", context.severity ?? "error"); + scope.setContext("executor.ui", { + surface: context.surface, + action: context.action, + message: context.message, + metadata: context.metadata, + }); + }); + }; + const FrontendErrorReporterContext = React.createContext( - defaultFrontendErrorReporter, + normalizing(reportViaGlobalErrorEvent), ); -let currentFrontendErrorReporter = defaultFrontendErrorReporter; +let currentFrontendErrorReporter = normalizing(reportViaGlobalErrorEvent); export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => { currentFrontendErrorReporter(error, context); @@ -41,7 +183,12 @@ export const reportHandledFrontendError = (error: unknown, context: FrontendErro export const FrontendErrorReporterProvider = ( props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>, ) => { - const reporter = props.reporter ?? defaultFrontendErrorReporter; + // Memoized so the wrapper keeps a stable identity across renders: consumers + // list it in `useCallback` dependencies. + const reporter = React.useMemo( + () => normalizing(props.reporter ?? reportViaGlobalErrorEvent), + [props.reporter], + ); currentFrontendErrorReporter = reporter; return ( @@ -53,12 +200,6 @@ export const FrontendErrorReporterProvider = ( export const useReportHandledError = (): FrontendErrorReporter => React.useContext(FrontendErrorReporterContext); -export const messageFromUnknown = (error: unknown, fallback: string): string => - Option.match(decodeErrorMessage(error), { - onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback), - onSome: ({ message }) => message, - }); - export const messageFromExit = (exit: Exit.Exit, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, @@ -71,7 +212,7 @@ export const reportExitFailure = ( context: FrontendErrorContext, ): void => { if (!Exit.isFailure(exit)) return; - report(exit.cause, context); + report(toReportableError(exit.cause, context), context); }; export const useErrorMessageFromExit = (): (( @@ -95,5 +236,5 @@ export const reportCauseFailure = ( cause: Cause.Cause, context: FrontendErrorContext, ): void => { - report(cause, context); + report(toReportableError(cause, context), context); };