Report handled frontend errors
Unified · Split
| @@ -0,0 +1,74 @@ | |||
| 1 | import React from "react"; | ||
| 2 | import { renderToString } from "react-dom/server"; | ||
| 3 | import { RegistryProvider, useAtomSet } from "@effect/atom-react"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | ||
| 8 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 9 | import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | |||
| 12 | const Widget = Schema.Struct({ name: Schema.String }); | ||
| 13 | |||
| 14 | const WidgetGroup = HttpApiGroup.make("widgets").add( | ||
| 15 | HttpApiEndpoint.get("load", "/widgets", { | ||
| 16 | success: Widget, | ||
| 17 | }), | ||
| 18 | ); | ||
| 19 | |||
| 20 | const WidgetApi = HttpApi.make("widget-api").add(WidgetGroup); | ||
| 21 | |||
| 22 | const malformedWidgetFetch = () => | ||
| 23 | Promise.resolve( | ||
| 24 | new Response(JSON.stringify({ name: 123 }), { | ||
| 25 | status: 200, | ||
| 26 | headers: { "content-type": "application/json" }, | ||
| 27 | }), | ||
| 28 | ); | ||
| 29 | |||
| 30 | const runAtomMutation = async ( | ||
| 31 | transformResponse?: ( | ||
| 32 | effect: Effect.Effect<unknown, unknown, unknown>, | ||
| 33 | ) => Effect.Effect<unknown, unknown, unknown>, | ||
| 34 | ) => { | ||
| 35 | const originalFetch = globalThis.fetch; | ||
| 36 | globalThis.fetch = malformedWidgetFetch as typeof fetch; | ||
| 37 | const WidgetClient = AtomHttpApi.Service<"WidgetClient">()("WidgetClient", { | ||
| 38 | api: WidgetApi, | ||
| 39 | baseUrl: "https://executor.test", | ||
| 40 | httpClient: FetchHttpClient.layer, | ||
| 41 | ...(transformResponse ? { transformResponse } : {}), | ||
| 42 | }); | ||
| 43 | const loadWidget = WidgetClient.mutation("widgets", "load"); | ||
| 44 | let runLoad: ((input: {}) => Promise<Exit.Exit<unknown, unknown>>) | undefined; | ||
| 45 | |||
| 46 | function CaptureMutation() { | ||
| 47 | runLoad = useAtomSet(loadWidget, { mode: "promiseExit" }); | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | |||
| 51 | renderToString(React.createElement(RegistryProvider, null, React.createElement(CaptureMutation))); | ||
| 52 | |||
| 53 | const exit = await runLoad!({}); | ||
| 54 | globalThis.fetch = originalFetch; | ||
| 55 | return exit; | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("AtomHttpApi frontend decode failures", () => { | ||
| 59 | it("returns malformed success responses as failed exits in promiseExit mode", async () => { | ||
| 60 | const exit = await runAtomMutation(); | ||
| 61 | |||
| 62 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("can report malformed success responses once through transformResponse", async () => { | ||
| 66 | const captured: unknown[] = []; | ||
| 67 | const exit = await runAtomMutation((effect) => | ||
| 68 | Effect.tapCause(effect, (cause) => Effect.sync(() => captured.push(cause))), | ||
| 69 | ); | ||
| 70 | |||
| 71 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 72 | expect(captured).toHaveLength(1); | ||
| 73 | }); | ||
| 74 | }); | ||
| @@ -4,7 +4,19 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; · 3 unmodified lines | |||
| 4 | import { AutumnProvider } from "autumn-js/react"; | 4 | import { AutumnProvider } from "autumn-js/react"; |
| 5 | import posthog from "posthog-js"; | 5 | import posthog from "posthog-js"; |
| 6 | import { PostHogProvider } from "posthog-js/react"; | 6 | import { PostHogProvider } from "posthog-js/react"; |
| 7 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; | ||
| 7 | import { ExecutorProvider } from "@executor-js/react/api/provider"; | 8 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 9 | import { Button } from "@executor-js/react/components/button"; | ||
| 10 | import { | ||
| 11 | Dialog, | ||
| 12 | DialogClose, | ||
| 13 | DialogContent, | ||
| 14 | DialogDescription, | ||
| 15 | DialogFooter, | ||
| 16 | DialogHeader, | ||
| 17 | DialogTitle, | ||
| 18 | DialogTrigger, | ||
| 19 | } from "@executor-js/react/components/dialog"; | ||
| 8 | import { Skeleton } from "@executor-js/react/components/skeleton"; | 20 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 9 | import { Toaster } from "@executor-js/react/components/sonner"; | 21 | import { Toaster } from "@executor-js/react/components/sonner"; |
| 10 | import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; | 22 | import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; |
| @@ -40,6 +52,21 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { · 29 unmodified lines | |||
| 40 | }); | 52 | }); |
| 41 | } | 53 | } |
| 42 | 54 | ||
| 55 | const captureFrontendError: FrontendErrorReporter = (error, context) => { | ||
| 56 | Sentry.captureException(error, (scope) => { | ||
| 57 | scope.setTag("executor.ui.surface", context.surface); | ||
| 58 | scope.setTag("executor.ui.action", context.action); | ||
| 59 | scope.setTag("executor.ui.severity", context.severity ?? "error"); | ||
| 60 | scope.setContext("executor.ui", { | ||
| 61 | surface: context.surface, | ||
| 62 | action: context.action, | ||
| 63 | message: context.message, | ||
| 64 | metadata: context.metadata, | ||
| 65 | }); | ||
| 66 | return scope; | ||
| 67 | }); | ||
| 68 | }; | ||
| 69 | |||
| 43 | export const Route = createRootRoute({ | 70 | export const Route = createRootRoute({ |
| 44 | head: () => ({ | 71 | head: () => ({ |
| 45 | meta: [ | 72 | meta: [ |
| @@ -151,6 +178,131 @@ function ShellSkeleton() { · 105 unmodified lines | |||
| 151 | ); | 178 | ); |
| 152 | } | 179 | } |
| 153 | 180 | ||
| 181 | function ShellErrorFallback() { | ||
| 182 | const [slackOpen, setSlackOpen] = React.useState(false); | ||
| 183 | const supportLinks = [ | ||
| 184 | { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark }, | ||
| 185 | { | ||
| 186 | label: "GitHub Issues", | ||
| 187 | href: "https://github.com/RhysSullivan/executor/issues", | ||
| 188 | icon: GitHubMark, | ||
| 189 | }, | ||
| 190 | { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark }, | ||
| 191 | ] as const; | ||
| 192 | |||
| 193 | return ( | ||
| 194 | <main className="flex min-h-screen items-center justify-center bg-background px-6 py-10"> | ||
| 195 | <section className="w-full max-w-md text-center"> | ||
| 196 | <div className="mx-auto mb-5 flex size-11 items-center justify-center rounded-full border border-border bg-muted"> | ||
| 197 | <span className="text-lg font-semibold text-muted-foreground">!</span> | ||
| 198 | </div> | ||
| 199 | <h1 className="text-xl font-semibold text-foreground">Something went wrong</h1> | ||
| 200 | <p className="mt-2 text-sm leading-6 text-muted-foreground"> | ||
| 201 | We've tracked it. Give refreshing a try, and get in touch if support is needed. | ||
| 202 | </p> | ||
| 203 | <p className="mt-6 text-xs font-medium uppercase tracking-wide text-muted-foreground"> | ||
| 204 | Get support | ||
| 205 | </p> | ||
| 206 | <div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-sm"> | ||
| 207 | <Dialog open={slackOpen} onOpenChange={setSlackOpen}> | ||
| 208 | <DialogTrigger asChild> | ||
| 209 | <Button type="button" variant="outline" size="sm" className="gap-2"> | ||
| 210 | <SlackMark className="size-4" /> | ||
| 211 | Slack | ||
| 212 | </Button> | ||
| 213 | </DialogTrigger> | ||
| 214 | <DialogContent> | ||
| 215 | <DialogHeader> | ||
| 216 | <DialogTitle>Slack Connect</DialogTitle> | ||
| 217 | <DialogDescription> | ||
| 218 | Invite <span className="font-medium text-foreground">rhys@executor.sh</span> to | ||
| 219 | Slack Connect. | ||
| 220 | </DialogDescription> | ||
| 221 | </DialogHeader> | ||
| 222 | <DialogFooter> | ||
| 223 | <DialogClose asChild> | ||
| 224 | <Button type="button" variant="outline"> | ||
| 225 | Done | ||
| 226 | </Button> | ||
| 227 | </DialogClose> | ||
| 228 | </DialogFooter> | ||
| 229 | </DialogContent> | ||
| 230 | </Dialog> | ||
| 231 | {supportLinks.map((link) => ( | ||
| 232 | // oxlint-disable-next-line react/jsx-no-new-function-as-prop -- static support link component choice | ||
| 233 | <a | ||
| 234 | key={link.label} | ||
| 235 | href={link.href} | ||
| 236 | className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 font-medium text-foreground transition-colors hover:bg-muted" | ||
| 237 | > | ||
| 238 | <link.icon className="size-4" /> | ||
| 239 | {link.label} | ||
| 240 | </a> | ||
| 241 | ))} | ||
| 242 | </div> | ||
| 243 | </section> | ||
| 244 | </main> | ||
| 245 | ); | ||
| 246 | } | ||
| 247 | |||
| 248 | function DiscordMark({ className }: { className?: string }) { | ||
| 249 | return ( | ||
| 250 | <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden> | ||
| 251 | <path d="M20.32 4.37A19.8 19.8 0 0 0 15.36 2.8a13.8 13.8 0 0 0-.64 1.32 18.4 18.4 0 0 0-5.44 0 13.8 13.8 0 0 0-.64-1.32 19.7 19.7 0 0 0-4.97 1.57C.53 9.09-.32 13.69.1 18.22a19.9 19.9 0 0 0 6.08 3.03 14.7 14.7 0 0 0 1.3-2.09 12.8 12.8 0 0 1-2.04-.97l.5-.38a14.2 14.2 0 0 0 12.12 0l.5.38c-.65.38-1.33.7-2.04.97.37.74.8 1.44 1.3 2.09a19.9 19.9 0 0 0 6.08-3.03c.5-5.25-.84-9.8-3.58-13.85ZM8.02 15.43c-1.18 0-2.15-1.08-2.15-2.4 0-1.33.95-2.41 2.15-2.41 1.2 0 2.17 1.09 2.15 2.4 0 1.33-.95 2.41-2.15 2.41Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.4 0-1.33.95-2.41 2.15-2.41 1.2 0 2.17 1.09 2.15 2.4 0 1.33-.95 2.41-2.15 2.41Z" /> | ||
| 252 | </svg> | ||
| 253 | ); | ||
| 254 | } | ||
| 255 | |||
| 256 | function GitHubMark({ className }: { className?: string }) { | ||
| 257 | return ( | ||
| 258 | <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden> | ||
| 259 | <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.15c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.71 1.26 3.37.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.19-3.08-.12-.29-.52-1.46.11-3.04 0 0 .98-.31 3.17 1.18a10.9 10.9 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.8 1.19 1.83 1.19 3.08 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.16c0 .31.21.67.79.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" /> | ||
| 260 | </svg> | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | |||
| 264 | function MailMark({ className }: { className?: string }) { | ||
| 265 | return ( | ||
| 266 | <svg | ||
| 267 | viewBox="0 0 24 24" | ||
| 268 | fill="none" | ||
| 269 | stroke="currentColor" | ||
| 270 | strokeWidth="1.8" | ||
| 271 | className={className} | ||
| 272 | aria-hidden | ||
| 273 | > | ||
| 274 | <path | ||
| 275 | strokeLinecap="round" | ||
| 276 | strokeLinejoin="round" | ||
| 277 | d="M3 7l9 6 9-6M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2Z" | ||
| 278 | /> | ||
| 279 | </svg> | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | |||
| 283 | function SlackMark({ className }: { className?: string }) { | ||
| 284 | return ( | ||
| 285 | <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden> | ||
| 286 | <path | ||
| 287 | d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" | ||
| 288 | fill="#E01E5A" | ||
| 289 | /> | ||
| 290 | <path | ||
| 291 | d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.527 2.527 0 0 1 2.521 2.521 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" | ||
| 292 | fill="#36C5F0" | ||
| 293 | /> | ||
| 294 | <path | ||
| 295 | d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.272 0a2.528 2.528 0 0 1-2.521 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.163 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" | ||
| 296 | fill="#2EB67D" | ||
| 297 | /> | ||
| 298 | <path | ||
| 299 | d="M15.163 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.163 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.272a2.527 2.527 0 0 1-2.521-2.521 2.527 2.527 0 0 1 2.521-2.521h6.315A2.527 2.527 0 0 1 24 15.163a2.528 2.528 0 0 1-2.522 2.521h-6.315z" | ||
| 300 | fill="#ECB22E" | ||
| 301 | /> | ||
| 302 | </svg> | ||
| 303 | ); | ||
| 304 | } | ||
| 305 | |||
| 154 | function AuthGate() { | 306 | function AuthGate() { |
| 155 | const auth = useAuth(); | 307 | const auth = useAuth(); |
| 156 | 308 | ||
| @@ -168,12 +320,14 @@ function AuthGate() { · 11 unmodified lines | |||
| 168 | 320 | ||
| 169 | return ( | 321 | return ( |
| 170 | <AutumnProvider pathPrefix="/api/autumn"> | 322 | <AutumnProvider pathPrefix="/api/autumn"> |
| 171 | <ExecutorProvider fallback={<ShellSkeleton />}> | 323 | <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}> |
| 172 | <ExecutorPluginsProvider plugins={clientPlugins}> | 324 | <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}> |
| 173 | <Shell /> | 325 | <ExecutorPluginsProvider plugins={clientPlugins}> |
| 174 | <Toaster /> | 326 | <Shell /> |
| 175 | </ExecutorPluginsProvider> | 327 | <Toaster /> |
| 176 | </ExecutorProvider> | 328 | </ExecutorPluginsProvider> |
| 329 | </ExecutorProvider> | ||
| 330 | </Sentry.ErrorBoundary> | ||
| 177 | </AutumnProvider> | 331 | </AutumnProvider> |
| 178 | ); | 332 | ); |
| 179 | } | 333 | } |
| @@ -1,8 +1,29 @@ | |||
| 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | ||
| 3 | import { ExecutorApi } from "@executor-js/api"; | 4 | import { ExecutorApi } from "@executor-js/api"; |
| 5 | import * as Cause from "effect/Cause"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 4 | 9 | ||
| 5 | import { getBaseUrl } from "./base-url"; | 10 | import { getBaseUrl } from "./base-url"; |
| 11 | import { reportHandledFrontendError } from "./error-reporting"; | ||
| 12 | |||
| 13 | const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean => | ||
| 14 | Option.match(Cause.findErrorOption(cause), { | ||
| 15 | onNone: () => false, | ||
| 16 | onSome: (error) => Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error), | ||
| 17 | }); | ||
| 18 | |||
| 19 | const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) => | ||
| 20 | Effect.sync(() => { | ||
| 21 | if (!isApiClientInfrastructureCause(cause)) return; | ||
| 22 | reportHandledFrontendError(cause, { | ||
| 23 | surface: "api_client", | ||
| 24 | action: "decode_or_transport", | ||
| 25 | }); | ||
| 26 | }); | ||
| 6 | 27 | ||
| 7 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| 8 | // Core API client — tools + secrets | 29 | // Core API client — tools + secrets |
| @@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines | |||
| 14 | transformClient: HttpClient.mapRequest((request) => | 35 | transformClient: HttpClient.mapRequest((request) => |
| 15 | HttpClientRequest.prependUrl(request, getBaseUrl()), | 36 | HttpClientRequest.prependUrl(request, getBaseUrl()), |
| 16 | ), | 37 | ), |
| 38 | transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), | ||
| 17 | }); | 39 | }); |
| 18 | 40 | ||
| 19 | export { ExecutorApiClient }; | 41 | export { ExecutorApiClient }; |
| @@ -0,0 +1,48 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import { | ||
| 5 | messageFromExit, | ||
| 6 | messageFromUnknown, | ||
| 7 | reportExitFailure, | ||
| 8 | type FrontendErrorContext, | ||
| 9 | } from "./error-reporting"; | ||
| 10 | |||
| 11 | describe("frontend error reporting", () => { | ||
| 12 | it("extracts stable messages from structured failures", () => { | ||
| 13 | expect(messageFromUnknown({ message: "Saved connection failed" }, "Fallback")).toBe( | ||
| 14 | "Saved connection failed", | ||
| 15 | ); | ||
| 16 | expect(messageFromUnknown("Plain failure", "Fallback")).toBe("Plain failure"); | ||
| 17 | expect(messageFromUnknown({ reason: "unknown" }, "Fallback")).toBe("Fallback"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("extracts stable messages from Effect exits", () => { | ||
| 21 | const exit = Exit.fail({ message: "Could not update source" }); | ||
| 22 | |||
| 23 | expect(messageFromExit(exit, "Fallback")).toBe("Could not update source"); | ||
| 24 | expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("reports failed exits with the provided context", () => { | ||
| 28 | const exit = Exit.fail({ message: "Could not update source" }); | ||
| 29 | const calls: Array<{ error: unknown; context: FrontendErrorContext }> = []; | ||
| 30 | |||
| 31 | reportExitFailure( | ||
| 32 | (error, context) => { | ||
| 33 | calls.push({ error, context }); | ||
| 34 | }, | ||
| 35 | exit, | ||
| 36 | { | ||
| 37 | surface: "sources", | ||
| 38 | action: "update", | ||
| 39 | message: "Could not update source", | ||
| 40 | }, | ||
| 41 | ); | ||
| 42 | |||
| 43 | expect(calls).toHaveLength(1); | ||
| 44 | expect(Cause.isCause(calls[0]!.error)).toBe(true); | ||
| 45 | expect(calls[0]!.context.surface).toBe("sources"); | ||
| 46 | expect(calls[0]!.context.action).toBe("update"); | ||
| 47 | }); | ||
| 48 | }); | ||
| @@ -0,0 +1,99 @@ | |||
| 1 | import * as React from "react"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | |||
| 8 | export type FrontendErrorContext = { | ||
| 9 | readonly surface: string; | ||
| 10 | readonly action: string; | ||
| 11 | readonly message?: string; | ||
| 12 | readonly severity?: "error" | "warning"; | ||
| 13 | readonly metadata?: Record<string, string | number | boolean | null | undefined>; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void; | ||
| 17 | |||
| 18 | class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ | ||
| 19 | readonly cause: unknown; | ||
| 20 | readonly context: FrontendErrorContext; | ||
| 21 | }> {} | ||
| 22 | |||
| 23 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 24 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 25 | |||
| 26 | const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => { | ||
| 27 | if (typeof globalThis.reportError !== "function") return; | ||
| 28 | globalThis.reportError(new FrontendHandledError({ cause: error, context })); | ||
| 29 | }; | ||
| 30 | |||
| 31 | const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>( | ||
| 32 | defaultFrontendErrorReporter, | ||
| 33 | ); | ||
| 34 | |||
| 35 | let currentFrontendErrorReporter = defaultFrontendErrorReporter; | ||
| 36 | |||
| 37 | export const reportHandledFrontendError = (error: unknown, context: FrontendErrorContext): void => { | ||
| 38 | currentFrontendErrorReporter(error, context); | ||
| 39 | }; | ||
| 40 | |||
| 41 | export const FrontendErrorReporterProvider = ( | ||
| 42 | props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>, | ||
| 43 | ) => { | ||
| 44 | const reporter = props.reporter ?? defaultFrontendErrorReporter; | ||
| 45 | currentFrontendErrorReporter = reporter; | ||
| 46 | return ( | ||
| 47 | <FrontendErrorReporterContext.Provider value={reporter}> | ||
| 48 | {props.children} | ||
| 49 | </FrontendErrorReporterContext.Provider> | ||
| 50 | ); | ||
| 51 | }; | ||
| 52 | |||
| 53 | export const useReportHandledError = (): FrontendErrorReporter => | ||
| 54 | React.useContext(FrontendErrorReporterContext); | ||
| 55 | |||
| 56 | export const messageFromUnknown = (error: unknown, fallback: string): string => | ||
| 57 | Option.match(decodeErrorMessage(error), { | ||
| 58 | onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback), | ||
| 59 | onSome: ({ message }) => message, | ||
| 60 | }); | ||
| 61 | |||
| 62 | export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | ||
| 63 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { | ||
| 64 | onNone: () => fallback, | ||
| 65 | onSome: ({ message }) => message, | ||
| 66 | }); | ||
| 67 | |||
| 68 | export const reportExitFailure = ( | ||
| 69 | report: FrontendErrorReporter, | ||
| 70 | exit: Exit.Exit<unknown, unknown>, | ||
| 71 | context: FrontendErrorContext, | ||
| 72 | ): void => { | ||
| 73 | if (!Exit.isFailure(exit)) return; | ||
| 74 | report(exit.cause, context); | ||
| 75 | }; | ||
| 76 | |||
| 77 | export const useErrorMessageFromExit = (): (( | ||
| 78 | exit: Exit.Exit<unknown, unknown>, | ||
| 79 | fallback: string, | ||
| 80 | context: Omit<FrontendErrorContext, "message"> & { readonly message?: string }, | ||
| 81 | ) => string) => { | ||
| 82 | const report = useReportHandledError(); | ||
| 83 | return React.useCallback( | ||
| 84 | (exit, fallback, context) => { | ||
| 85 | const message = messageFromExit(exit, fallback); | ||
| 86 | reportExitFailure(report, exit, { ...context, message: context.message ?? message }); | ||
| 87 | return message; | ||
| 88 | }, | ||
| 89 | [report], | ||
| 90 | ); | ||
| 91 | }; | ||
| 92 | |||
| 93 | export const reportCauseFailure = ( | ||
| 94 | report: FrontendErrorReporter, | ||
| 95 | cause: Cause.Cause<unknown>, | ||
| 96 | context: FrontendErrorContext, | ||
| 97 | ): void => { | ||
| 98 | report(cause, context); | ||
| 99 | }; | ||
| @@ -1,11 +1,17 @@ | |||
| 1 | import { RegistryProvider } from "@effect/atom-react"; | 1 | import { RegistryProvider } from "@effect/atom-react"; |
| 2 | import * as React from "react"; | 2 | import * as React from "react"; |
| 3 | import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; | ||
| 3 | import { ScopeProvider } from "./scope-context"; | 4 | import { ScopeProvider } from "./scope-context"; |
| 4 | 5 | ||
| 5 | export const ExecutorProvider = ( | 6 | export const ExecutorProvider = ( |
| 6 | props: React.PropsWithChildren<{ fallback?: React.ReactNode }>, | 7 | props: React.PropsWithChildren<{ |
| 8 | fallback?: React.ReactNode; | ||
| 9 | onHandledError?: FrontendErrorReporter; | ||
| 10 | }>, | ||
| 7 | ) => ( | 11 | ) => ( |
| 8 | <RegistryProvider> | 12 | <FrontendErrorReporterProvider reporter={props.onHandledError}> |
| 9 | <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider> | 13 | <RegistryProvider> |
| 10 | </RegistryProvider> | 14 | <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider> |
| 15 | </RegistryProvider> | ||
| 16 | </FrontendErrorReporterProvider> | ||
| 11 | ); | 17 | ); |
| @@ -1,11 +1,11 @@ | |||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Data from "effect/Data"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | 6 | ||
| 8 | import { cancelOAuth, startOAuth } from "../api/atoms"; | 7 | import { cancelOAuth, startOAuth } from "../api/atoms"; |
| 8 | import { messageFromUnknown, useReportHandledError } from "../api/error-reporting"; | ||
| 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; | 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 10 | import { Button } from "../components/button"; | 10 | import { Button } from "../components/button"; |
| 11 | import { | 11 | import { |
| @@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines | |||
| 46 | readonly authorizationUrl: string | null; | 46 | readonly authorizationUrl: string | null; |
| 47 | }; | 47 | }; |
| 48 | 48 | ||
| 49 | class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ | ||
| 50 | readonly cause: unknown; | ||
| 51 | }> {} | ||
| 52 | |||
| 49 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { | 53 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { |
| 50 | readonly tokenScope: string; | 54 | readonly tokenScope: string; |
| 51 | readonly run: () => Promise<OAuthAuthorizationStartResult>; | 55 | readonly run: () => Promise<OAuthAuthorizationStartResult>; |
| 52 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; | 56 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; |
| 53 | readonly onError?: (error: string) => void; | 57 | readonly onError?: (error: string) => void; |
| 54 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; | 58 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; |
| 59 | readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>; | ||
| 55 | }; | 60 | }; |
| 56 | 61 | ||
| 57 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { | 62 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { |
| @@ -73,16 +78,6 @@ const oauthRouteParamsForTokenScope = ( · 15 unmodified lines | |||
| 73 | scopeId: ScopeId.make(String(tokenScope)), | 78 | scopeId: ScopeId.make(String(tokenScope)), |
| 74 | }); | 79 | }); |
| 75 | 80 | ||
| 76 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | ||
| 77 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | ||
| 78 | |||
| 79 | const oauthPersistenceErrorMessage = (cause: unknown): string => | ||
| 80 | Option.match(decodeErrorMessage(cause), { | ||
| 81 | onNone: () => | ||
| 82 | typeof cause === "string" && cause.length > 0 ? cause : "Failed to save connection", | ||
| 83 | onSome: ({ message }) => message, | ||
| 84 | }); | ||
| 85 | |||
| 86 | export function useOAuthPopupFlow< | 81 | export function useOAuthPopupFlow< |
| 87 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, | 82 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, |
| 88 | >(options: { | 83 | >(options: { |
| @@ -103,6 +98,7 @@ export function useOAuthPopupFlow< · 14 unmodified lines | |||
| 103 | } = options; | 98 | } = options; |
| 104 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); | 99 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 105 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); | 100 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); |
| 101 | const reportHandledError = useReportHandledError(); | ||
| 106 | const [busy, setBusy] = useState(false); | 102 | const [busy, setBusy] = useState(false); |
| 107 | const [error, setError] = useState<string | null>(null); | 103 | const [error, setError] = useState<string | null>(null); |
| 108 | const cleanupRef = useRef<(() => void) | null>(null); | 104 | const cleanupRef = useRef<(() => void) | null>(null); |
| @@ -157,11 +153,17 @@ export function useOAuthPopupFlow< · 48 unmodified lines | |||
| 157 | const startExit = await Effect.runPromiseExit( | 153 | const startExit = await Effect.runPromiseExit( |
| 158 | Effect.tryPromise({ | 154 | Effect.tryPromise({ |
| 159 | try: input.run, | 155 | try: input.run, |
| 160 | catch: (cause) => cause, | 156 | catch: (cause) => new OAuthAuthorizationStartError({ cause }), |
| 161 | }), | 157 | }), |
| 162 | ); | 158 | ); |
| 163 | if (Exit.isFailure(startExit)) { | 159 | if (Exit.isFailure(startExit)) { |
| 164 | const message = startErrorMessage ?? "Failed to start sign-in"; | 160 | const message = startErrorMessage ?? "Failed to start sign-in"; |
| 161 | reportHandledError(startExit.cause, { | ||
| 162 | surface: "oauth", | ||
| 163 | action: "start", | ||
| 164 | message, | ||
| 165 | metadata: input.reportMetadata, | ||
| 166 | }); | ||
| 165 | reservedPopup.popup.close(); | 167 | reservedPopup.popup.close(); |
| 166 | setBusy(false); | 168 | setBusy(false); |
| 167 | setError(message); | 169 | setError(message); |
| @@ -206,7 +208,13 @@ export function useOAuthPopupFlow< · 38 unmodified lines | |||
| 206 | (cause: unknown) => cause, | 208 | (cause: unknown) => cause, |
| 207 | ); | 209 | ); |
| 208 | if (persistenceError !== null) { | 210 | if (persistenceError !== null) { |
| 209 | const message = oauthPersistenceErrorMessage(persistenceError); | 211 | const message = messageFromUnknown(persistenceError, "Failed to save connection"); |
| 212 | reportHandledError(persistenceError, { | ||
| 213 | surface: "oauth", | ||
| 214 | action: "persist_connection", | ||
| 215 | message, | ||
| 216 | metadata: input.reportMetadata, | ||
| 217 | }); | ||
| 210 | setBusy(false); | 218 | setBusy(false); |
| 211 | setError(message); | 219 | setError(message); |
| 212 | input.onError?.(message); | 220 | input.onError?.(message); |
| @@ -243,6 +251,7 @@ export function useOAuthPopupFlow< · 30 unmodified lines | |||
| 243 | popupBlockedMessage, | 251 | popupBlockedMessage, |
| 244 | popupClosedMessage, | 252 | popupClosedMessage, |
| 245 | popupName, | 253 | popupName, |
| 254 | reportHandledError, | ||
| 246 | startErrorMessage, | 255 | startErrorMessage, |
| 247 | ], | 256 | ], |
| 248 | ); | 257 | ); |
| @@ -254,6 +263,11 @@ export function useOAuthPopupFlow< · 5 unmodified lines | |||
| 254 | onSuccess: input.onSuccess, | 263 | onSuccess: input.onSuccess, |
| 255 | onError: input.onError, | 264 | onError: input.onError, |
| 256 | onAuthorizationStarted: input.onAuthorizationStarted, | 265 | onAuthorizationStarted: input.onAuthorizationStarted, |
| 266 | reportMetadata: { | ||
| 267 | pluginId: input.payload.pluginId, | ||
| 268 | connectionId: input.payload.connectionId, | ||
| 269 | tokenScope: input.payload.tokenScope, | ||
| 270 | }, | ||
| 257 | run: () => | 271 | run: () => |
| 258 | doStartOAuth({ | 272 | doStartOAuth({ |
| 259 | params: oauthRouteParamsForTokenScope(input.payload.tokenScope), | 273 | params: oauthRouteParamsForTokenScope(input.payload.tokenScope), |