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==