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
4import { AutumnProvider } from "autumn-js/react";4import { AutumnProvider } from "autumn-js/react";
5import posthog from "posthog-js";5import posthog from "posthog-js";
6import { PostHogProvider } from "posthog-js/react";6import { PostHogProvider } from "posthog-js/react";
7import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
7import { ExecutorProvider } from "@executor-js/react/api/provider";8import { ExecutorProvider } from "@executor-js/react/api/provider";
8import { Skeleton } from "@executor-js/react/components/skeleton";9import { Skeleton } from "@executor-js/react/components/skeleton";
9import { Toaster } from "@executor-js/react/components/sonner";10import { 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
44const 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
43export const Route = createRootRoute({59export const Route = createRootRoute({
44 head: () => ({60 head: () => ({
45 meta: [61 meta: [
@@ -168,12 +184,14 @@ function AuthGate() { · 122 unmodified lines
168 184
169 return (185 return (
170 <AutumnProvider pathPrefix="/api/autumn">186 <AutumnProvider pathPrefix="/api/autumn">
171 <ExecutorProvider fallback={<ShellSkeleton />}>187 <Sentry.ErrorBoundary fallback={<ShellSkeleton />} showDialog={false}>
172 <ExecutorPluginsProvider plugins={clientPlugins}>188 <ExecutorProvider fallback={<ShellSkeleton />} onHandledError={captureFrontendError}>
173 <Shell />189 <ExecutorPluginsProvider plugins={clientPlugins}>
174 <Toaster />190 <Shell />
175 </ExecutorPluginsProvider>191 <Toaster />
176 </ExecutorProvider>192 </ExecutorPluginsProvider>
193 </ExecutorProvider>
194 </Sentry.ErrorBoundary>
177 </AutumnProvider>195 </AutumnProvider>
178 );196 );
179}197}
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+8−12
@@ -1,9 +1,8 @@
1import { useCallback, useState } from "react";1import { useCallback, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6 4
5import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting";
7import { useScope } from "@executor-js/react/api/scope-context";6import { useScope } from "@executor-js/react/api/scope-context";
8import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";7import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
9import {8import {
@@ -45,15 +44,6 @@ import { addGraphqlSourceOptimistic } from "./atoms"; · 35 unmodified lines
45import { initialGraphqlCredentials } from "./defaults";44import { initialGraphqlCredentials } from "./defaults";
46import type { GraphqlCredentialInput } from "../sdk/types";45import type { GraphqlCredentialInput } from "../sdk/types";
47 46
48const ErrorMessage = Schema.Struct({ message: Schema.String });
49const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
50
51const 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
57type AuthMode = "none" | "oauth2";47type AuthMode = "none" | "oauth2";
58 48
59export default function AddGraphqlSource(props: {49export default function AddGraphqlSource(props: {
@@ -70,6 +60,7 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
70 const [addError, setAddError] = useState<string | null>(null);60 const [addError, setAddError] = useState<string | null>(null);
71 const [authMode, setAuthMode] = useState<AuthMode>("none");61 const [authMode, setAuthMode] = useState<AuthMode>("none");
72 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);62 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
63 const errorMessageFromExit = useErrorMessageFromExit();
73 64
74 const scopeId = useScope();65 const scopeId = useScope();
75 const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();66 const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
@@ -170,7 +161,12 @@ export default function AddGraphqlSource(props: { · 94 unmodified lines
170 reactivityKeys: sourceWriteKeys,161 reactivityKeys: sourceWriteKeys,
171 });162 });
172 if (Exit.isFailure(exit)) {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 setAdding(false);170 setAdding(false);
175 return;171 return;
176 }172 }
packages/plugins/mcp/src/react/AddMcpSource.tsx+28−16
@@ -1,9 +1,8 @@
1import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";1import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6 4
5import { useErrorMessageFromExit } from "@executor-js/react/api/error-reporting";
7import { useScope } from "@executor-js/react/api/scope-context";6import { useScope } from "@executor-js/react/api/scope-context";
8import { Button } from "@executor-js/react/components/button";7import { Button } from "@executor-js/react/components/button";
9import {8import {
@@ -60,15 +59,6 @@ import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; · 50 unmodified lines
60import { mcpPresets, type McpPreset } from "../sdk/presets";59import { mcpPresets, type McpPreset } from "../sdk/presets";
61import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";60import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
62 61
63const ErrorMessage = Schema.Struct({ message: Schema.String });
64const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
65
66const 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// Preset lookup63// Preset lookup
74// ---------------------------------------------------------------------------64// ---------------------------------------------------------------------------
@@ -301,6 +291,7 @@ export default function AddMcpSource(props: { · 226 unmodified lines
301 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {291 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
302 mode: "promiseExit",292 mode: "promiseExit",
303 });293 });
294 const errorMessageFromExit = useErrorMessageFromExit();
304 const secretList = useSecretPickerSecrets();295 const secretList = useSecretPickerSecrets();
305 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({296 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
306 popupName: "mcp-oauth",297 popupName: "mcp-oauth",
@@ -357,13 +348,16 @@ export default function AddMcpSource(props: { · 50 unmodified lines
357 if (Exit.isFailure(exit)) {348 if (Exit.isFailure(exit)) {
358 dispatch({349 dispatch({
359 type: "probe-fail",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 return;356 return;
363 }357 }
364 setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none");358 setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none");
365 dispatch({ type: "probe-ok", probe: exit.value });359 dispatch({ type: "probe-ok", probe: exit.value });
366 }, [state.url, scopeId, doProbe, remoteCredentials]);360 }, [state.url, scopeId, doProbe, remoteCredentials, errorMessageFromExit]);
367 361
368 // Keep the latest handleProbe in a ref so the debounced effect can call it362 // Keep the latest handleProbe in a ref so the debounced effect can call it
369 // without depending on its identity (which changes every render).363 // without depending on its identity (which changes every render).
@@ -483,7 +477,10 @@ export default function AddMcpSource(props: { · 113 unmodified lines
483 if (Exit.isFailure(exit)) {477 if (Exit.isFailure(exit)) {
484 dispatch({478 dispatch({
485 type: "add-fail",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 return;485 return;
489 }486 }
@@ -499,6 +496,7 @@ export default function AddMcpSource(props: { · 9 unmodified lines
499 doAdd,496 doAdd,
500 props,497 props,
501 scopeId,498 scopeId,
499 errorMessageFromExit,
502 requestCredentialTargetScope,500 requestCredentialTargetScope,
503 oauthCredentialTargetScope,501 oauthCredentialTargetScope,
504 ]);502 ]);
@@ -549,12 +547,26 @@ export default function AddMcpSource(props: { · 44 unmodified lines
549 reactivityKeys: sourceWriteKeys,547 reactivityKeys: sourceWriteKeys,
550 });548 });
551 if (Exit.isFailure(exit)) {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 setStdioAdding(false);556 setStdioAdding(false);
554 return;557 return;
555 }558 }
556 props.onComplete();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 // ---- Render ----571 // ---- Render ----
560 572
packages/react/src/api/error-reporting.test.ts+48−0
@@ -0,0 +1,48 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Exit from "effect/Exit";
4import {
5 messageFromExit,
6 messageFromUnknown,
7 reportExitFailure,
8 type FrontendErrorContext,
9} from "./error-reporting";
10
11describe("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 @@
1import * as React from "react";
2import * as Cause from "effect/Cause";
3import * as Data from "effect/Data";
4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8export 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
16export type FrontendErrorReporter = (error: unknown, context: FrontendErrorContext) => void;
17
18class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{
19 readonly cause: unknown;
20 readonly context: FrontendErrorContext;
21}> {}
22
23const ErrorMessage = Schema.Struct({ message: Schema.String });
24const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
25
26const defaultFrontendErrorReporter: FrontendErrorReporter = (error, context) => {
27 if (typeof globalThis.reportError !== "function") return;
28 globalThis.reportError(new FrontendHandledError({ cause: error, context }));
29};
30
31const FrontendErrorReporterContext = React.createContext<FrontendErrorReporter>(
32 defaultFrontendErrorReporter,
33);
34
35export const FrontendErrorReporterProvider = (
36 props: React.PropsWithChildren<{ reporter?: FrontendErrorReporter }>,
37) => (
38 <FrontendErrorReporterContext.Provider value={props.reporter ?? defaultFrontendErrorReporter}>
39 {props.children}
40 </FrontendErrorReporterContext.Provider>
41);
42
43export const useReportHandledError = (): FrontendErrorReporter =>
44 React.useContext(FrontendErrorReporterContext);
45
46export 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
52export 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
58export 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
67export 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
83export 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 @@
1import { RegistryProvider } from "@effect/atom-react";1import { RegistryProvider } from "@effect/atom-react";
2import * as React from "react";2import * as React from "react";
3import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";
3import { ScopeProvider } from "./scope-context";4import { ScopeProvider } from "./scope-context";
4 5
5export const ExecutorProvider = (6export 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);
packages/react/src/plugins/oauth-sign-in.tsx+28−14
@@ -1,11 +1,11 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7 6
8import { cancelOAuth, startOAuth } from "../api/atoms";7import { cancelOAuth, startOAuth } from "../api/atoms";
8import { messageFromUnknown, useReportHandledError } from "../api/error-reporting";
9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";9import { openOAuthPopup, reserveOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
10import { Button } from "../components/button";10import { Button } from "../components/button";
11import {11import {
@@ -46,12 +46,17 @@ export type OAuthAuthorizationStartResult = { · 34 unmodified lines
46 readonly authorizationUrl: string | null;46 readonly authorizationUrl: string | null;
47};47};
48 48
49class OAuthAuthorizationStartError extends Data.TaggedError("OAuthAuthorizationStartError")<{
50 readonly cause: unknown;
51}> {}
52
49export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload> = {53export 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
57export function oauthCallbackUrl(path = "/api/oauth/callback"): string {62export 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
76const ErrorMessage = Schema.Struct({ message: Schema.String });
77const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
78
79const 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
86export function useOAuthPopupFlow<81export 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),