History

Merge pull request #727 from RhysSullivan/hotfix/oauth-popup-open-result

Report handled frontend errors

Rhys Sullivan committed 6 months ago

Commit 1cc59952 parents 0061e64 2256b0c7 files · +441 −24Browse files

Unified · Split

7 files
  1. apps/cloud/src/frontend-atom-error-capture.node.test.ts +74 −0
  2. apps/cloud/src/routes/__root.tsx +160 −6
  3. packages/react/src/api/client.tsx +22 −0
  4. packages/react/src/api/error-reporting.test.ts +48 −0
  5. packages/react/src/api/error-reporting.tsx +99 −0
  6. packages/react/src/api/provider.tsx +10 −4
  7. packages/react/src/plugins/oauth-sign-in.tsx +28 −14
apps/cloud/src/frontend-atom-error-capture.node.test.ts+74−0
@@ -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+});
apps/cloud/src/routes/__root.tsx+160−6
@@ -4,7 +4,19 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; · 3 unmodified lines
44 import { AutumnProvider } from "autumn-js/react";
55 import posthog from "posthog-js";
66 import { PostHogProvider } from "posthog-js/react";
7+import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
78 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";
820 import { Skeleton } from "@executor-js/react/components/skeleton";
921 import { Toaster } from "@executor-js/react/components/sonner";
1022 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
4052 });
4153 }
4254
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+
4370 export const Route = createRootRoute({
4471 head: () => ({
4572 meta: [
@@ -151,6 +178,131 @@ function ShellSkeleton() { · 105 unmodified lines
151178 );
152179 }
153180
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&apos;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+
154306 function AuthGate() {
155307 const auth = useAuth();
156308
@@ -168,12 +320,14 @@ function AuthGate() { · 11 unmodified lines
168320
169321 return (
170322 <AutumnProvider pathPrefix="/api/autumn">
171− <ExecutorProvider fallback={<ShellSkeleton />}>
172− <ExecutorPluginsProvider plugins={clientPlugins}>
173− <Shell />
174− <Toaster />
175− </ExecutorPluginsProvider>
176− </ExecutorProvider>
323+ <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
324+ <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
325+ <ExecutorPluginsProvider plugins={clientPlugins}>
326+ <Shell />
327+ <Toaster />
328+ </ExecutorPluginsProvider>
329+ </ExecutorProvider>
330+ </Sentry.ErrorBoundary>
177331 </AutumnProvider>
178332 );
179333 }
packages/react/src/api/client.tsx+22−0
@@ -1,8 +1,29 @@
11 import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
22 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3+import * as HttpClientError from "effect/unstable/http/HttpClientError";
34 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";
49
510 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+ });
627
728 // ---------------------------------------------------------------------------
829 // Core API client — tools + secrets
@@ -14,6 +35,7 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 5 unmodified lines
1435 transformClient: HttpClient.mapRequest((request) =>
1536 HttpClientRequest.prependUrl(request, getBaseUrl()),
1637 ),
38+ transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
1739 });
1840
1941 export { ExecutorApiClient };
packages/react/src/api/error-reporting.test.ts+48−0
@@ -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+});
packages/react/src/api/error-reporting.tsx+99−0
@@ -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+};
packages/react/src/api/provider.tsx+10−4
@@ -1,11 +1,17 @@
11 import { RegistryProvider } from "@effect/atom-react";
22 import * as React from "react";
3+import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";
34 import { ScopeProvider } from "./scope-context";
45
56 export const ExecutorProvider = (
6− props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
7+ props: React.PropsWithChildren<{
8+ fallback?: React.ReactNode;
9+ onHandledError?: FrontendErrorReporter;
10+ }>,
711 ) => (
8− <RegistryProvider>
9− <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider>
10− </RegistryProvider>
12+ <FrontendErrorReporterProvider reporter={props.onHandledError}>
13+ <RegistryProvider>
14+ <ScopeProvider fallback={props.fallback}>{props.children}</ScopeProvider>
15+ </RegistryProvider>
16+ </FrontendErrorReporterProvider>
1117 );
packages/react/src/plugins/oauth-sign-in.tsx+28−14
@@ -1,11 +1,11 @@
11 import { useCallback, useEffect, useRef, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
3+import * as Data from "effect/Data";
34 import * as Effect from "effect/Effect";
45 import * as Exit from "effect/Exit";
5−import * as Option from "effect/Option";
6−import * as Schema from "effect/Schema";
76
87 import { cancelOAuth, startOAuth } from "../api/atoms";
8+import { messageFromUnknown, useReportHandledError } from "../api/error-reporting";
99 import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
1010 import { Button } from "../components/button";
1111 import {
@@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines
4646 readonly authorizationUrl: string | null;
4747 };
4848
49+class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{
50+ readonly cause: unknown;
51+}> {}
52+
4953 export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = {
5054 readonly tokenScope: string;
5155 readonly run: () => Promise<OAuthAuthorizationStartResult>;
5256 readonly onSuccess: (payload: TPayload) => void | Promise<void>;
5357 readonly onError?: (error: string) => void;
5458 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;
59+ readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;
5560 };
5661
5762 export function oauthCallbackUrl(path = "/api/oauth/callback"): string {
@@ -73,16 +78,6 @@ const oauthRouteParamsForTokenScope = ( · 15 unmodified lines
7378 scopeId: ScopeId.make(String(tokenScope)),
7479 });
7580
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−
8681 export function useOAuthPopupFlow<
8782 TPayload extends OAuthCompletionPayload = OAuthCompletionPayload,
8883 >(options: {
@@ -103,6 +98,7 @@ export function useOAuthPopupFlow< · 14 unmodified lines
10398 } = options;
10499 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
105100 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
101+ const reportHandledError = useReportHandledError();
106102 const [busy, setBusy] = useState(false);
107103 const [error, setError] = useState<string | null>(null);
108104 const cleanupRef = useRef<(() => void) | null>(null);
@@ -157,11 +153,17 @@ export function useOAuthPopupFlow< · 48 unmodified lines
157153 const startExit = await Effect.runPromiseExit(
158154 Effect.tryPromise({
159155 try: input.run,
160− catch: (cause) => cause,
156+ catch: (cause) => new OAuthAuthorizationStartError({ cause }),
161157 }),
162158 );
163159 if (Exit.isFailure(startExit)) {
164160 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+ });
165167 reservedPopup.popup.close();
166168 setBusy(false);
167169 setError(message);
@@ -206,7 +208,13 @@ export function useOAuthPopupFlow< · 38 unmodified lines
206208 (cause: unknown) => cause,
207209 );
208210 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+ });
210218 setBusy(false);
211219 setError(message);
212220 input.onError?.(message);
@@ -243,6 +251,7 @@ export function useOAuthPopupFlow< · 30 unmodified lines
243251 popupBlockedMessage,
244252 popupClosedMessage,
245253 popupName,
254+ reportHandledError,
246255 startErrorMessage,
247256 ],
248257 );
@@ -254,6 +263,11 @@ export function useOAuthPopupFlow< · 5 unmodified lines
254263 onSuccess: input.onSuccess,
255264 onError: input.onError,
256265 onAuthorizationStarted: input.onAuthorizationStarted,
266+ reportMetadata: {
267+ pluginId: input.payload.pluginId,
268+ connectionId: input.payload.connectionId,
269+ tokenScope: input.payload.tokenScope,
270+ },
257271 run: () =>
258272 doStartOAuth({
259273 params: oauthRouteParamsForTokenScope(input.payload.tokenScope),