Unified · Split
| @@ -4,6 +4,7 @@ import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; · 3 unmodified lines | ||
| 4 | 4 | import { AutumnProvider } from "autumn-js/react"; |
| 5 | 5 | import posthog from "posthog-js"; |
| 6 | 6 | import { PostHogProvider } from "posthog-js/react"; |
| 7 | +import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; | |
| 7 | 8 | import { ExecutorProvider } from "@executor-js/react/api/provider"; |
| 8 | 9 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 9 | 10 | 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 | ||
| 40 | 41 | }); |
| 41 | 42 | } |
| 42 | 43 | |
| 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 | + | |
| 43 | 59 | export const Route = createRootRoute({ |
| 44 | 60 | head: () => ({ |
| 45 | 61 | meta: [ |
| @@ -168,12 +184,14 @@ function AuthGate() { · 122 unmodified lines | ||
| 168 | 184 | |
| 169 | 185 | return ( |
| 170 | 186 | <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> | |
| 177 | 195 | </AutumnProvider> |
| 178 | 196 | ); |
| 179 | 197 | } |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { useCallback, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | −import * as Option from "effect/Option"; | |
| 5 | −import * as Schema from "effect/Schema"; | |
| 6 | 4 | |
| 5 | +import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting"; | |
| 7 | 6 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | 7 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 9 | 8 | import { |
| @@ -45,15 +44,6 @@ import { addGraphqlSourceOptimistic } from "./atoms"; · 35 unmodified lines | ||
| 45 | 44 | import { initialGraphqlCredentials } from "./defaults"; |
| 46 | 45 | import type { GraphqlCredentialInput } from "../sdk/types"; |
| 47 | 46 | |
| 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 | − | |
| 57 | 47 | type AuthMode = "none" | "oauth2"; |
| 58 | 48 | |
| 59 | 49 | export default function AddGraphqlSource(props: { |
| @@ -70,6 +60,7 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines | ||
| 70 | 60 | const [addError, setAddError] = useState<string | null>(null); |
| 71 | 61 | const [authMode, setAuthMode] = useState<AuthMode>("none"); |
| 72 | 62 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); |
| 63 | + const errorMessageFromExit = useErrorMessageFromExit(); | |
| 73 | 64 | |
| 74 | 65 | const scopeId = useScope(); |
| 75 | 66 | const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); |
| @@ -170,7 +161,12 @@ export default function AddGraphqlSource(props: { · 94 unmodified lines | ||
| 170 | 161 | reactivityKeys: sourceWriteKeys, |
| 171 | 162 | }); |
| 172 | 163 | 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 | + ); | |
| 174 | 170 | setAdding(false); |
| 175 | 171 | return; |
| 176 | 172 | } |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | −import * as Option from "effect/Option"; | |
| 5 | −import * as Schema from "effect/Schema"; | |
| 6 | 4 | |
| 5 | +import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting"; | |
| 7 | 6 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | 7 | import { Button } from "@executor-js/react/components/button"; |
| 9 | 8 | import { |
| @@ -60,15 +59,6 @@ import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; · 50 unmodified lines | ||
| 60 | 59 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 61 | 60 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; |
| 62 | 61 | |
| 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 | − | |
| 72 | 62 | // --------------------------------------------------------------------------- |
| 73 | 63 | // Preset lookup |
| 74 | 64 | // --------------------------------------------------------------------------- |
| @@ -301,6 +291,7 @@ export default function AddMcpSource(props: { · 226 unmodified lines | ||
| 301 | 291 | const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { |
| 302 | 292 | mode: "promiseExit", |
| 303 | 293 | }); |
| 294 | + const errorMessageFromExit = useErrorMessageFromExit(); | |
| 304 | 295 | const secretList = useSecretPickerSecrets(); |
| 305 | 296 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 306 | 297 | popupName: "mcp-oauth", |
| @@ -357,13 +348,16 @@ export default function AddMcpSource(props: { · 50 unmodified lines | ||
| 357 | 348 | if (Exit.isFailure(exit)) { |
| 358 | 349 | dispatch({ |
| 359 | 350 | 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 | + }), | |
| 361 | 355 | }); |
| 362 | 356 | return; |
| 363 | 357 | } |
| 364 | 358 | setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none"); |
| 365 | 359 | dispatch({ type: "probe-ok", probe: exit.value }); |
| 366 | − }, [state.url, scopeId, doProbe, remoteCredentials]); | |
| 360 | + }, [state.url, scopeId, doProbe, remoteCredentials, errorMessageFromExit]); | |
| 367 | 361 | |
| 368 | 362 | // Keep the latest handleProbe in a ref so the debounced effect can call it |
| 369 | 363 | // without depending on its identity (which changes every render). |
| @@ -483,7 +477,10 @@ export default function AddMcpSource(props: { · 113 unmodified lines | ||
| 483 | 477 | if (Exit.isFailure(exit)) { |
| 484 | 478 | dispatch({ |
| 485 | 479 | 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 | + }), | |
| 487 | 484 | }); |
| 488 | 485 | return; |
| 489 | 486 | } |
| @@ -499,6 +496,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines | ||
| 499 | 496 | doAdd, |
| 500 | 497 | props, |
| 501 | 498 | scopeId, |
| 499 | + errorMessageFromExit, | |
| 502 | 500 | requestCredentialTargetScope, |
| 503 | 501 | oauthCredentialTargetScope, |
| 504 | 502 | ]); |
| @@ -549,12 +547,26 @@ export default function AddMcpSource(props: { · 44 unmodified lines | ||
| 549 | 547 | reactivityKeys: sourceWriteKeys, |
| 550 | 548 | }); |
| 551 | 549 | 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 | + ); | |
| 553 | 556 | setStdioAdding(false); |
| 554 | 557 | return; |
| 555 | 558 | } |
| 556 | 559 | 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 | + ]); | |
| 558 | 570 | |
| 559 | 571 | // ---- Render ---- |
| 560 | 572 | |
| @@ -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,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 | +}; | |
| @@ -1,11 +1,17 @@ | ||
| 1 | 1 | import { RegistryProvider } from "@effect/atom-react"; |
| 2 | 2 | import * as React from "react"; |
| 3 | +import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; | |
| 3 | 4 | import { ScopeProvider } from "./scope-context"; |
| 4 | 5 | |
| 5 | 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> | |
| 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> | |
| 11 | 17 | ); |
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | +import * as Data from "effect/Data"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Exit from "effect/Exit"; |
| 5 | −import * as Option from "effect/Option"; | |
| 6 | −import * as Schema from "effect/Schema"; | |
| 7 | 6 | |
| 8 | 7 | import { cancelOAuth, startOAuth } from "../api/atoms"; |
| 8 | +import { messageFromUnknown, useReportHandledError } from "../api/error-reporting"; | |
| 9 | 9 | import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup"; |
| 10 | 10 | import { Button } from "../components/button"; |
| 11 | 11 | import { |
| @@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines | ||
| 46 | 46 | readonly authorizationUrl: string | null; |
| 47 | 47 | }; |
| 48 | 48 | |
| 49 | +class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{ | |
| 50 | + readonly cause: unknown; | |
| 51 | +}> {} | |
| 52 | + | |
| 49 | 53 | export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = { |
| 50 | 54 | readonly tokenScope: string; |
| 51 | 55 | readonly run: () => Promise<OAuthAuthorizationStartResult>; |
| 52 | 56 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; |
| 53 | 57 | readonly onError?: (error: string) => void; |
| 54 | 58 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; |
| 59 | + readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>; | |
| 55 | 60 | }; |
| 56 | 61 | |
| 57 | 62 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { |
| @@ -73,16 +78,6 @@ const oauthRouteParamsForTokenScope = ( · 15 unmodified lines | ||
| 73 | 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 | 81 | export function useOAuthPopupFlow< |
| 87 | 82 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, |
| 88 | 83 | >(options: { |
| @@ -103,6 +98,7 @@ export function useOAuthPopupFlow< · 14 unmodified lines | ||
| 103 | 98 | } = options; |
| 104 | 99 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 105 | 100 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); |
| 101 | + const reportHandledError = useReportHandledError(); | |
| 106 | 102 | const [busy, setBusy] = useState(false); |
| 107 | 103 | const [error, setError] = useState<string | null>(null); |
| 108 | 104 | const cleanupRef = useRef<(() => void) | null>(null); |
| @@ -157,11 +153,17 @@ export function useOAuthPopupFlow< · 48 unmodified lines | ||
| 157 | 153 | const startExit = await Effect.runPromiseExit( |
| 158 | 154 | Effect.tryPromise({ |
| 159 | 155 | try: input.run, |
| 160 | − catch: (cause) => cause, | |
| 156 | + catch: (cause) => new OAuthAuthorizationStartError({ cause }), | |
| 161 | 157 | }), |
| 162 | 158 | ); |
| 163 | 159 | if (Exit.isFailure(startExit)) { |
| 164 | 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 | 167 | reservedPopup.popup.close(); |
| 166 | 168 | setBusy(false); |
| 167 | 169 | setError(message); |
| @@ -206,7 +208,13 @@ export function useOAuthPopupFlow< · 38 unmodified lines | ||
| 206 | 208 | (cause: unknown) => cause, |
| 207 | 209 | ); |
| 208 | 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 | 218 | setBusy(false); |
| 211 | 219 | setError(message); |
| 212 | 220 | input.onError?.(message); |
| @@ -243,6 +251,7 @@ export function useOAuthPopupFlow< · 30 unmodified lines | ||
| 243 | 251 | popupBlockedMessage, |
| 244 | 252 | popupClosedMessage, |
| 245 | 253 | popupName, |
| 254 | + reportHandledError, | |
| 246 | 255 | startErrorMessage, |
| 247 | 256 | ], |
| 248 | 257 | ); |
| @@ -254,6 +263,11 @@ export function useOAuthPopupFlow< · 5 unmodified lines | ||
| 254 | 263 | onSuccess: input.onSuccess, |
| 255 | 264 | onError: input.onError, |
| 256 | 265 | onAuthorizationStarted: input.onAuthorizationStarted, |
| 266 | + reportMetadata: { | |
| 267 | + pluginId: input.payload.pluginId, | |
| 268 | + connectionId: input.payload.connectionId, | |
| 269 | + tokenScope: input.payload.tokenScope, | |
| 270 | + }, | |
| 257 | 271 | run: () => |
| 258 | 272 | doStartOAuth({ |
| 259 | 273 | params: oauthRouteParamsForTokenScope(input.payload.tokenScope), |