History

Report handled frontend errors

Rhys Sullivan committed 6 months ago

Commit 2fdd0001 parent 2084be07 files · +235 −52Browse files

Unified · Split

7 files
  1. apps/cloud/src/routes/__root.tsx +24 −6
  2. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +8 −12
  3. packages/plugins/mcp/src/react/AddMcpSource.tsx +28 −16
  4. packages/react/src/api/error-reporting.test.ts +48 −0
  5. packages/react/src/api/error-reporting.tsx +89 −0
  6. packages/react/src/api/provider.tsx +10 −4
  7. packages/react/src/plugins/oauth-sign-in.tsx +28 −14
apps/cloud/src/routes/__root.tsx+24−6
@@ -4,6 +4,7 @@ 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";
89 import { Skeleton } from "@executor-js/react/components/skeleton";
910 import { Toaster } from "@executor-js/react/components/sonner";
@@ -40,6 +41,21 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { · 30 unmodified lines
4041 });
4142 }
4243
44+const captureFrontendError: FrontendErrorReporter = (error, context) => {
45+ Sentry.captureException(error, (scope) => {
46+ scope.setTag("executor.ui.surface", context.surface);
47+ scope.setTag("executor.ui.action", context.action);
48+ scope.setTag("executor.ui.severity", context.severity ?? "error");
49+ scope.setContext("executor.ui", {
50+ surface: context.surface,
51+ action: context.action,
52+ message: context.message,
53+ metadata: context.metadata,
54+ });
55+ return scope;
56+ });
57+};
58+
4359 export const Route = createRootRoute({
4460 head: () => ({
4561 meta: [
@@ -168,12 +184,14 @@ function AuthGate() { · 122 unmodified lines
168184
169185 return (
170186 <AutumnProvider pathPrefix="/api/autumn">
171− <ExecutorProvider fallback={<ShellSkeleton />}>
172− <ExecutorPluginsProvider plugins={clientPlugins}>
173− <Shell />
174− <Toaster />
175− </ExecutorPluginsProvider>
176− </ExecutorProvider>
187+ <Sentry.ErrorBoundary fallback={<ShellSkeleton />} showDialog={false}>
188+ <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
189+ <ExecutorPluginsProvider plugins={clientPlugins}>
190+ <Shell />
191+ <Toaster />
192+ </ExecutorPluginsProvider>
193+ </ExecutorProvider>
194+ </Sentry.ErrorBoundary>
177195 </AutumnProvider>
178196 );
179197 }
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+8−12
@@ -1,9 +1,8 @@
11 import { useCallback, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
4−import * as Option from "effect/Option";
5−import * as Schema from "effect/Schema";
64
5+import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting";
76 import { useScope } from "@executor-js/react/api/scope-context";
87 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
98 import {
@@ -45,15 +44,6 @@ import { addGraphqlSourceOptimistic } from "./atoms"; · 35 unmodified lines
4544 import { initialGraphqlCredentials } from "./defaults";
4645 import type { GraphqlCredentialInput } from "../sdk/types";
4746
48−const ErrorMessage = Schema.Struct({ message: Schema.String });
49−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
50−
51−const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
52− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
53− onNone: () => fallback,
54− onSome: ({ message }) => message,
55− });
56−
5747 type AuthMode = "none" | "oauth2";
5848
5949 export default function AddGraphqlSource(props: {
@@ -70,6 +60,7 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
7060 const [addError, setAddError] = useState<string | null>(null);
7161 const [authMode, setAuthMode] = useState<AuthMode>("none");
7262 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
63+ const errorMessageFromExit = useErrorMessageFromExit();
7364
7465 const scopeId = useScope();
7566 const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
@@ -170,7 +161,12 @@ export default function AddGraphqlSource(props: { · 94 unmodified lines
170161 reactivityKeys: sourceWriteKeys,
171162 });
172163 if (Exit.isFailure(exit)) {
173− setAddError(errorMessageFromExit(exit, "Failed to add source"));
164+ setAddError(
165+ errorMessageFromExit(exit, "Failed to add source", {
166+ surface: "graphql_source",
167+ action: "add_source",
168+ }),
169+ );
174170 setAdding(false);
175171 return;
176172 }
packages/plugins/mcp/src/react/AddMcpSource.tsx+28−16
@@ -1,9 +1,8 @@
11 import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
4−import * as Option from "effect/Option";
5−import * as Schema from "effect/Schema";
64
5+import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting";
76 import { useScope } from "@executor-js/react/api/scope-context";
87 import { Button } from "@executor-js/react/components/button";
98 import {
@@ -60,15 +59,6 @@ import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; · 50 unmodified lines
6059 import { mcpPresets, type McpPreset } from "../sdk/presets";
6160 import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
6261
63−const ErrorMessage = Schema.Struct({ message: Schema.String });
64−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
65−
66−const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
67− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
68− onNone: () => fallback,
69− onSome: ({ message }) => message,
70− });
71−
7262 // ---------------------------------------------------------------------------
7363 // Preset lookup
7464 // ---------------------------------------------------------------------------
@@ -301,6 +291,7 @@ export default function AddMcpSource(props: { · 226 unmodified lines
301291 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
302292 mode: "promiseExit",
303293 });
294+ const errorMessageFromExit = useErrorMessageFromExit();
304295 const secretList = useSecretPickerSecrets();
305296 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
306297 popupName: "mcp-oauth",
@@ -357,13 +348,16 @@ export default function AddMcpSource(props: { · 50 unmodified lines
357348 if (Exit.isFailure(exit)) {
358349 dispatch({
359350 type: "probe-fail",
360− error: errorMessageFromExit(exit, "Failed to connect"),
351+ error: errorMessageFromExit(exit, "Failed to connect", {
352+ surface: "mcp_source",
353+ action: "probe_remote_source",
354+ }),
361355 });
362356 return;
363357 }
364358 setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none");
365359 dispatch({ type: "probe-ok", probe: exit.value });
366− }, [state.url, scopeId, doProbe, remoteCredentials]);
360+ }, [state.url, scopeId, doProbe, remoteCredentials, errorMessageFromExit]);
367361
368362 // Keep the latest handleProbe in a ref so the debounced effect can call it
369363 // without depending on its identity (which changes every render).
@@ -483,7 +477,10 @@ export default function AddMcpSource(props: { · 113 unmodified lines
483477 if (Exit.isFailure(exit)) {
484478 dispatch({
485479 type: "add-fail",
486− error: errorMessageFromExit(exit, "Failed to add source"),
480+ error: errorMessageFromExit(exit, "Failed to add source", {
481+ surface: "mcp_source",
482+ action: "add_remote_source",
483+ }),
487484 });
488485 return;
489486 }
@@ -499,6 +496,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines
499496 doAdd,
500497 props,
501498 scopeId,
499+ errorMessageFromExit,
502500 requestCredentialTargetScope,
503501 oauthCredentialTargetScope,
504502 ]);
@@ -549,12 +547,26 @@ export default function AddMcpSource(props: { · 44 unmodified lines
549547 reactivityKeys: sourceWriteKeys,
550548 });
551549 if (Exit.isFailure(exit)) {
552− setStdioError(errorMessageFromExit(exit, "Failed to add source"));
550+ setStdioError(
551+ errorMessageFromExit(exit, "Failed to add source", {
552+ surface: "mcp_source",
553+ action: "add_stdio_source",
554+ }),
555+ );
553556 setStdioAdding(false);
554557 return;
555558 }
556559 props.onComplete();
557− }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]);
560+ }, [
561+ stdioCommand,
562+ stdioArgs,
563+ stdioEnv,
564+ stdioIdentity,
565+ doAdd,
566+ scopeId,
567+ props,
568+ errorMessageFromExit,
569+ ]);
558570
559571 // ---- Render ----
560572
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+89−0
@@ -0,0 +1,89 @@
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+export const FrontendErrorReporterProvider = (
36+ props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
37+) => (
38+ <FrontendErrorReporterContext.Provider value={props.reporter ?? defaultFrontendErrorReporter}>
39+ {props.children}
40+ </FrontendErrorReporterContext.Provider>
41+);
42+
43+export const useReportHandledError = (): FrontendErrorReporter =>
44+ React.useContext(FrontendErrorReporterContext);
45+
46+export const messageFromUnknown = (error: unknown, fallback: string): string =>
47+ Option.match(decodeErrorMessage(error), {
48+ onNone: () => (typeof error === "string" && error.length > 0 ? error : fallback),
49+ onSome: ({ message }) => message,
50+ });
51+
52+export const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
53+ Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
54+ onNone: () => fallback,
55+ onSome: ({ message }) => message,
56+ });
57+
58+export const reportExitFailure = (
59+ report: FrontendErrorReporter,
60+ exit: Exit.Exit<unknown, unknown>,
61+ context: FrontendErrorContext,
62+): void => {
63+ if (!Exit.isFailure(exit)) return;
64+ report(exit.cause, context);
65+};
66+
67+export const useErrorMessageFromExit = (): ((
68+ exit: Exit.Exit<unknown, unknown>,
69+ fallback: string,
70+ context: Omit<FrontendErrorContext, "message"> & { readonly message?: string },
71+) => string) => {
72+ const report = useReportHandledError();
73+ return React.useCallback(
74+ (exit, fallback, context) => {
75+ const message = messageFromExit(exit, fallback);
76+ reportExitFailure(report, exit, { ...context, message: context.message ?? message });
77+ return message;
78+ },
79+ [report],
80+ );
81+};
82+
83+export const reportCauseFailure = (
84+ report: FrontendErrorReporter,
85+ cause: Cause.Cause<unknown>,
86+ context: FrontendErrorContext,
87+): void => {
88+ report(cause, context);
89+};
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),