From 5916d0ac279617c92c963c4e2d5de236679ed4b4 Mon Sep 17 00:00:00 2001 From: minnakt <47064971+minnakt@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:40:17 -0500 Subject: [PATCH] DEVPROD-7857: Send Sentry alerts for 422 errors in Parsley (#473) --- apps/parsley/package.json | 1 + .../src/components/FullPageLoad/index.tsx | 16 ---- apps/parsley/src/gql/GQLProvider.tsx | 79 +++---------------- apps/parsley/src/gql/client/link/index.ts | 17 ++++ .../gql/client/link/logGQLErrorsLink.test.ts | 43 ++++++++++ .../src/gql/client/link/logGQLErrorsLink.ts | 51 ++++++++++++ .../gql/client/useCreateGQLClient/index.ts | 67 ++++++++++++++++ apps/parsley/src/gql/fetch/index.ts | 17 ++++ apps/parsley/src/gql/generated/types.ts | 10 +++ apps/parsley/src/gql/queries/index.ts | 2 + .../src/gql/queries/secret-fields.graphql | 5 ++ apps/parsley/src/pages/index.tsx | 2 +- apps/spruce/package.json | 2 +- apps/spruce/src/components/Content/Layout.tsx | 2 +- .../src/components/SpruceLoader/index.tsx | 2 +- apps/spruce/src/gql/GQLWrapper.tsx | 4 +- .../client}/useCreateGQLClient/index.ts | 0 .../lib/src/components/FullPageLoad/index.tsx | 0 yarn.lock | 12 +-- 19 files changed, 234 insertions(+), 98 deletions(-) delete mode 100644 apps/parsley/src/components/FullPageLoad/index.tsx create mode 100644 apps/parsley/src/gql/client/link/index.ts create mode 100644 apps/parsley/src/gql/client/link/logGQLErrorsLink.test.ts create mode 100644 apps/parsley/src/gql/client/link/logGQLErrorsLink.ts create mode 100644 apps/parsley/src/gql/client/useCreateGQLClient/index.ts create mode 100644 apps/parsley/src/gql/fetch/index.ts create mode 100644 apps/parsley/src/gql/queries/secret-fields.graphql rename apps/spruce/src/{hooks => gql/client}/useCreateGQLClient/index.ts (100%) rename apps/spruce/src/components/Loading/FullPageLoad.tsx => packages/lib/src/components/FullPageLoad/index.tsx (100%) diff --git a/apps/parsley/package.json b/apps/parsley/package.json index 80ea49e67..66a7f1d83 100644 --- a/apps/parsley/package.json +++ b/apps/parsley/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@apollo/client": "3.10.4", + "@apollo/server": "^4.11.2", "@emotion/react": "11.13.3", "@emotion/styled": "11.13.0", "@evg-ui/deploy-utils": "*", diff --git a/apps/parsley/src/components/FullPageLoad/index.tsx b/apps/parsley/src/components/FullPageLoad/index.tsx deleted file mode 100644 index 0a25dab2d..000000000 --- a/apps/parsley/src/components/FullPageLoad/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import styled from "@emotion/styled"; -import { Body } from "@leafygreen-ui/typography"; - -export const FullPageLoad: React.FC = () => ( - - LOADING... - -); - -const FullPage = styled.div` - width: 100vw; - height: 100vh; - display: flex; - align-items: center; - justify-content: center; -`; diff --git a/apps/parsley/src/gql/GQLProvider.tsx b/apps/parsley/src/gql/GQLProvider.tsx index fcfd894db..942f20a6e 100644 --- a/apps/parsley/src/gql/GQLProvider.tsx +++ b/apps/parsley/src/gql/GQLProvider.tsx @@ -1,74 +1,13 @@ -import { - ApolloClient, - ApolloProvider, - HttpLink, - InMemoryCache, - from, -} from "@apollo/client"; -import { onError } from "@apollo/client/link/error"; -import { RetryLink } from "@apollo/client/link/retry"; -import { graphqlURL } from "utils/environmentVariables"; -import { reportError } from "utils/errorReporting"; - -const logErrorsLink = onError(({ graphQLErrors, operation }) => { - if (Array.isArray(graphQLErrors)) { - graphQLErrors.forEach((gqlErr) => { - const fingerprint = [operation.operationName]; - if (gqlErr?.path?.length) { - fingerprint.push(...gqlErr.path); - } - reportError(new Error(gqlErr.message), { - context: { - gqlErr, - variables: operation.variables, - }, - fingerprint, - tags: { operationName: operation.operationName }, - }).warning(); - }); +import { ApolloProvider } from "@apollo/client"; +import { FullPageLoad } from "@evg-ui/lib/components/FullPageLoad"; +import { useCreateGQLClient } from "gql/client/useCreateGQLClient"; + +const GQLProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const client = useCreateGQLClient(); + if (!client) { + return ; } -}); - -const retryLink = new RetryLink({ - attempts: { - max: 5, - retryIf: (error): boolean => - error && error.response && error.response.status >= 500, - }, - delay: { - initial: 300, - jitter: true, - max: 3000, - }, -}); - -interface ClientLinkParams { - credentials?: string; - gqlURL?: string; -} - -const getGQLClient = ({ credentials, gqlURL }: ClientLinkParams) => { - const cache = new InMemoryCache(); - const link = new HttpLink({ - credentials, - uri: gqlURL, - }); - const client = new ApolloClient({ - cache, - link: from([logErrorsLink, retryLink, link]), - }); - return client; + return {children}; }; -const GQLProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - -); - export default GQLProvider; diff --git a/apps/parsley/src/gql/client/link/index.ts b/apps/parsley/src/gql/client/link/index.ts new file mode 100644 index 000000000..0b0a158e2 --- /dev/null +++ b/apps/parsley/src/gql/client/link/index.ts @@ -0,0 +1,17 @@ +import { RetryLink } from "@apollo/client/link/retry"; +import { logGQLErrorsLink } from "./logGQLErrorsLink"; + +const retryLink = new RetryLink({ + attempts: { + max: 5, + retryIf: (error): boolean => + error && error.response && error.response.status >= 500, + }, + delay: { + initial: 300, + jitter: true, + max: 3000, + }, +}); + +export { retryLink, logGQLErrorsLink }; diff --git a/apps/parsley/src/gql/client/link/logGQLErrorsLink.test.ts b/apps/parsley/src/gql/client/link/logGQLErrorsLink.test.ts new file mode 100644 index 000000000..4e0f6b02e --- /dev/null +++ b/apps/parsley/src/gql/client/link/logGQLErrorsLink.test.ts @@ -0,0 +1,43 @@ +import { Operation } from "@apollo/client"; +import { GraphQLError } from "graphql"; +import * as ErrorReporting from "utils/errorReporting"; +import { reportingFn } from "./logGQLErrorsLink"; + +describe("reportingFn", () => { + beforeEach(() => { + vi.spyOn(ErrorReporting, "reportError"); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it("reportError should be called with secret fields redacted", () => { + const secretFields = ["password", "creditCard"]; + const operation: Operation = { + extensions: {}, + getContext: vi.fn(), + operationName: "exampleOperation", + // @ts-ignore-error: It's not necessary to run an actual query. + query: null, + setContext: vi.fn(), + variables: { + input: { creditCard: "1234567890123456", password: "password123" }, + }, + }; + const gqlErr = new GraphQLError("An error occurred", { + path: ["a", "path", "1"], + }); + + reportingFn(secretFields, operation)(gqlErr); + + expect(ErrorReporting.reportError).toHaveBeenCalledTimes(1); + expect(ErrorReporting.reportError).toHaveBeenCalledWith(expect.any(Error), { + context: { + gqlErr, + variables: { input: { creditCard: "REDACTED", password: "REDACTED" } }, + }, + fingerprint: ["exampleOperation", "a", "path", "1"], + tags: { operationName: "exampleOperation" }, + }); + }); +}); diff --git a/apps/parsley/src/gql/client/link/logGQLErrorsLink.ts b/apps/parsley/src/gql/client/link/logGQLErrorsLink.ts new file mode 100644 index 000000000..4fbf3c2e3 --- /dev/null +++ b/apps/parsley/src/gql/client/link/logGQLErrorsLink.ts @@ -0,0 +1,51 @@ +import { Operation } from "@apollo/client"; +import { onError } from "@apollo/client/link/error"; +import { ApolloServerErrorCode } from "@apollo/server/errors"; +import { GraphQLError } from "graphql"; +import { deleteNestedKey } from "@evg-ui/lib/utils/object"; +import { reportError } from "utils/errorReporting"; + +export const reportingFn = + (secretFields: string[], operation: Operation) => (gqlErr: GraphQLError) => { + const fingerprint = [operation.operationName]; + const path = gqlErr?.path?.map((v) => v.toString()); + if (path) { + fingerprint.push(...path); + } + + const isValidationError = + gqlErr.extensions?.code === + ApolloServerErrorCode.GRAPHQL_VALIDATION_FAILED; + + const err = isValidationError + ? new GraphQLError( + `GraphQL validation error in '${operation.operationName}': ${gqlErr.message}`, + ) + : new Error( + `Error occurred in '${operation.operationName}': ${gqlErr.message}`, + ); + + const sendError = reportError(err, { + context: { + gqlErr, + variables: deleteNestedKey( + operation.variables, + secretFields, + "REDACTED", + ), + }, + fingerprint, + tags: { operationName: operation.operationName }, + }); + + if (isValidationError) { + sendError.severe(); + } else { + sendError.warning(); + } + }; + +export const logGQLErrorsLink = (secretFields: string[]) => + onError(({ graphQLErrors, operation }) => + graphQLErrors?.forEach(reportingFn(secretFields, operation)), + ); diff --git a/apps/parsley/src/gql/client/useCreateGQLClient/index.ts b/apps/parsley/src/gql/client/useCreateGQLClient/index.ts new file mode 100644 index 000000000..096ee23c2 --- /dev/null +++ b/apps/parsley/src/gql/client/useCreateGQLClient/index.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { + ApolloClient, + HttpLink, + InMemoryCache, + NormalizedCacheObject, + from, +} from "@apollo/client"; +import { + fetchWithRetry, + shouldLogoutAndRedirect, +} from "@evg-ui/lib/utils/request"; +import { useAuthContext } from "context/auth"; +import { logGQLErrorsLink, retryLink } from "gql/client/link"; +import { secretFieldsReq } from "gql/fetch"; +import { SecretFieldsQuery } from "gql/generated/types"; +import { graphqlURL, isDevelopmentBuild } from "utils/environmentVariables"; +import { SentryBreadcrumb, leaveBreadcrumb } from "utils/errorReporting"; + +export const useCreateGQLClient = (): ApolloClient => { + const { logoutAndRedirect } = useAuthContext(); + const [gqlClient, setGQLClient] = useState(); + + // SecretFields are not necessary for development builds because nothing is logged. + const [secretFields, setSecretFields] = useState( + isDevelopmentBuild() ? [] : undefined, + ); + + useEffect(() => { + fetchWithRetry(graphqlURL ?? "", secretFieldsReq) + .then(({ data }) => { + setSecretFields(data?.spruceConfig?.secretFields); + }) + .catch((err) => { + leaveBreadcrumb( + "SecretFields Query Error", + { + err, + }, + SentryBreadcrumb.HTTP, + ); + if (shouldLogoutAndRedirect(err?.cause?.statusCode)) { + logoutAndRedirect(); + } + }); + }, [logoutAndRedirect]); + + useEffect(() => { + if (secretFields && !gqlClient) { + const cache = new InMemoryCache(); + const client = new ApolloClient({ + cache, + link: from([ + logGQLErrorsLink(secretFields), + retryLink, + new HttpLink({ + credentials: "include", + uri: graphqlURL, + }), + ]), + }); + setGQLClient(client); + } + }, [secretFields, gqlClient, logoutAndRedirect]); + + return gqlClient; +}; diff --git a/apps/parsley/src/gql/fetch/index.ts b/apps/parsley/src/gql/fetch/index.ts new file mode 100644 index 000000000..17319c31c --- /dev/null +++ b/apps/parsley/src/gql/fetch/index.ts @@ -0,0 +1,17 @@ +import { OperationDefinitionNode } from "graphql"; +import { SECRET_FIELDS } from "gql/queries"; + +export const secretFieldsReq: RequestInit = { + body: JSON.stringify({ + operationName: (SECRET_FIELDS.definitions[0] as OperationDefinitionNode) + ?.name?.value, + query: SECRET_FIELDS.loc?.source.body, + variables: {}, + }), + credentials: "include" as RequestCredentials, + headers: { + "content-type": "application/json", + }, + method: "POST", + mode: "cors" as RequestMode, +}; diff --git a/apps/parsley/src/gql/generated/types.ts b/apps/parsley/src/gql/generated/types.ts index b9f8fc82e..e02461f10 100644 --- a/apps/parsley/src/gql/generated/types.ts +++ b/apps/parsley/src/gql/generated/types.ts @@ -3760,6 +3760,16 @@ export type ProjectFiltersQuery = { }; }; +export type SecretFieldsQueryVariables = Exact<{ [key: string]: never }>; + +export type SecretFieldsQuery = { + __typename?: "Query"; + spruceConfig?: { + __typename?: "SpruceConfig"; + secretFields: Array; + } | null; +}; + export type TaskFilesQueryVariables = Exact<{ taskId: Scalars["String"]["input"]; execution?: InputMaybe; diff --git a/apps/parsley/src/gql/queries/index.ts b/apps/parsley/src/gql/queries/index.ts index 4a672f119..28def03ce 100644 --- a/apps/parsley/src/gql/queries/index.ts +++ b/apps/parsley/src/gql/queries/index.ts @@ -4,6 +4,7 @@ import GET_TEST_LOG_URL_AND_RENDERING_TYPE from "./get-test-log-url-and-renderin import GET_USER from "./get-user.graphql"; import PARSLEY_SETTINGS from "./parsley-settings.graphql"; import PROJECT_FILTERS from "./project-filters.graphql"; +import SECRET_FIELDS from "./secret-fields.graphql"; import TASK_FILES from "./task-files.graphql"; export { @@ -13,5 +14,6 @@ export { GET_USER, PARSLEY_SETTINGS, PROJECT_FILTERS, + SECRET_FIELDS, TASK_FILES, }; diff --git a/apps/parsley/src/gql/queries/secret-fields.graphql b/apps/parsley/src/gql/queries/secret-fields.graphql new file mode 100644 index 000000000..53e6b82a7 --- /dev/null +++ b/apps/parsley/src/gql/queries/secret-fields.graphql @@ -0,0 +1,5 @@ +query SecretFields { + spruceConfig { + secretFields + } +} diff --git a/apps/parsley/src/pages/index.tsx b/apps/parsley/src/pages/index.tsx index 62e515153..b302a07bd 100644 --- a/apps/parsley/src/pages/index.tsx +++ b/apps/parsley/src/pages/index.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy } from "react"; import { Navigate, Outlet, Route, Routes } from "react-router-dom"; +import { FullPageLoad } from "@evg-ui/lib/components/FullPageLoad"; import { useAnalyticAttributes } from "analytics"; -import { FullPageLoad } from "components/FullPageLoad"; import NavBar from "components/NavBar"; import { PageLayout } from "components/styles"; import { LogTypes } from "constants/enums"; diff --git a/apps/spruce/package.json b/apps/spruce/package.json index 6cabad66b..18803280a 100644 --- a/apps/spruce/package.json +++ b/apps/spruce/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@apollo/client": "3.10.4", - "@apollo/server": "^4.11.0", + "@apollo/server": "^4.11.2", "@emotion/css": "11.13.4", "@emotion/react": "11.13.3", "@emotion/styled": "11.13.0", diff --git a/apps/spruce/src/components/Content/Layout.tsx b/apps/spruce/src/components/Content/Layout.tsx index 40d7ccc92..9073529dd 100644 --- a/apps/spruce/src/components/Content/Layout.tsx +++ b/apps/spruce/src/components/Content/Layout.tsx @@ -3,10 +3,10 @@ import styled from "@emotion/styled"; import { palette } from "@leafygreen-ui/palette"; import Cookies from "js-cookie"; import { Outlet } from "react-router-dom"; +import { FullPageLoad } from "@evg-ui/lib/components/FullPageLoad"; import { useAnalyticsAttributes } from "analytics"; import { Feedback } from "components/Feedback"; import { Header } from "components/Header"; -import { FullPageLoad } from "components/Loading/FullPageLoad"; import { SiteLayout } from "components/styles/Layout"; import { TaskStatusIconLegend } from "components/TaskStatusIconLegend"; import WelcomeModal from "components/WelcomeModal"; diff --git a/apps/spruce/src/components/SpruceLoader/index.tsx b/apps/spruce/src/components/SpruceLoader/index.tsx index e60b0484b..7e4f0b227 100644 --- a/apps/spruce/src/components/SpruceLoader/index.tsx +++ b/apps/spruce/src/components/SpruceLoader/index.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from "react"; -import { FullPageLoad } from "components/Loading/FullPageLoad"; +import { FullPageLoad } from "@evg-ui/lib/components/FullPageLoad"; export const loadable = < C extends React.ComponentType< diff --git a/apps/spruce/src/gql/GQLWrapper.tsx b/apps/spruce/src/gql/GQLWrapper.tsx index 21abaa123..c33847d7f 100644 --- a/apps/spruce/src/gql/GQLWrapper.tsx +++ b/apps/spruce/src/gql/GQLWrapper.tsx @@ -1,6 +1,6 @@ import { ApolloProvider } from "@apollo/client"; -import { FullPageLoad } from "components/Loading/FullPageLoad"; -import { useCreateGQLClient } from "hooks/useCreateGQLClient"; +import { FullPageLoad } from "@evg-ui/lib/components/FullPageLoad"; +import { useCreateGQLClient } from "gql/client/useCreateGQLClient"; const GQLWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { const client = useCreateGQLClient(); diff --git a/apps/spruce/src/hooks/useCreateGQLClient/index.ts b/apps/spruce/src/gql/client/useCreateGQLClient/index.ts similarity index 100% rename from apps/spruce/src/hooks/useCreateGQLClient/index.ts rename to apps/spruce/src/gql/client/useCreateGQLClient/index.ts diff --git a/apps/spruce/src/components/Loading/FullPageLoad.tsx b/packages/lib/src/components/FullPageLoad/index.tsx similarity index 100% rename from apps/spruce/src/components/Loading/FullPageLoad.tsx rename to packages/lib/src/components/FullPageLoad/index.tsx diff --git a/yarn.lock b/yarn.lock index a6973df1c..111841c44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -122,10 +122,10 @@ "@apollo/utils.keyvaluecache" "^2.1.0" "@apollo/utils.logger" "^2.0.0" -"@apollo/server@^4.11.0": - version "4.11.0" - resolved "https://registry.yarnpkg.com/@apollo/server/-/server-4.11.0.tgz#21c0f10ad805192a5485e58ed5c5b3dbe2243174" - integrity sha512-SWDvbbs0wl2zYhKG6aGLxwTJ72xpqp0awb2lotNpfezd9VcAvzaUizzKQqocephin2uMoaA8MguoyBmgtPzNWw== +"@apollo/server@^4.11.2": + version "4.11.2" + resolved "https://registry.yarnpkg.com/@apollo/server/-/server-4.11.2.tgz#7e4a5c76456aded36f45f2d87cddd3d8d7960c27" + integrity sha512-WUTHY7DDek8xAMn4Woa9Bl8duQUDzRYQkosX/d1DtCsBWESZyApR7ndnI5d6+W4KSTtqBHhJFkusEI7CWuIJXg== dependencies: "@apollo/cache-control-types" "^1.0.3" "@apollo/server-gateway-interface" "^1.1.1" @@ -143,7 +143,7 @@ "@types/node-fetch" "^2.6.1" async-retry "^1.2.1" cors "^2.8.5" - express "^4.17.1" + express "^4.21.1" loglevel "^1.6.8" lru-cache "^7.10.1" negotiator "^0.6.3" @@ -7185,7 +7185,7 @@ executable@^4.1.1: dependencies: pify "^2.2.0" -express@^4.17.1: +express@^4.21.1: version "4.21.1" resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==