Skip to content

Commit

Permalink
DEVPROD-7857: Send Sentry alerts for 422 errors in Parsley (#473)
Browse files Browse the repository at this point in the history
  • Loading branch information
minnakt authored Nov 5, 2024
1 parent cc84fe1 commit 5916d0a
Show file tree
Hide file tree
Showing 19 changed files with 234 additions and 98 deletions.
1 change: 1 addition & 0 deletions apps/parsley/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
16 changes: 0 additions & 16 deletions apps/parsley/src/components/FullPageLoad/index.tsx

This file was deleted.

79 changes: 9 additions & 70 deletions apps/parsley/src/gql/GQLProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 <FullPageLoad />;
}
});

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 <ApolloProvider client={client}>{children}</ApolloProvider>;
};

const GQLProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ApolloProvider
client={getGQLClient({
credentials: "include",
gqlURL: graphqlURL,
})}
>
{children}
</ApolloProvider>
);

export default GQLProvider;
17 changes: 17 additions & 0 deletions apps/parsley/src/gql/client/link/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
43 changes: 43 additions & 0 deletions apps/parsley/src/gql/client/link/logGQLErrorsLink.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
51 changes: 51 additions & 0 deletions apps/parsley/src/gql/client/link/logGQLErrorsLink.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
67 changes: 67 additions & 0 deletions apps/parsley/src/gql/client/useCreateGQLClient/index.ts
Original file line number Diff line number Diff line change
@@ -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<NormalizedCacheObject> => {
const { logoutAndRedirect } = useAuthContext();
const [gqlClient, setGQLClient] = useState<any>();

// SecretFields are not necessary for development builds because nothing is logged.
const [secretFields, setSecretFields] = useState<string[] | undefined>(
isDevelopmentBuild() ? [] : undefined,
);

useEffect(() => {
fetchWithRetry<SecretFieldsQuery>(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;
};
17 changes: 17 additions & 0 deletions apps/parsley/src/gql/fetch/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
10 changes: 10 additions & 0 deletions apps/parsley/src/gql/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3760,6 +3760,16 @@ export type ProjectFiltersQuery = {
};
};

export type SecretFieldsQueryVariables = Exact<{ [key: string]: never }>;

export type SecretFieldsQuery = {
__typename?: "Query";
spruceConfig?: {
__typename?: "SpruceConfig";
secretFields: Array<string>;
} | null;
};

export type TaskFilesQueryVariables = Exact<{
taskId: Scalars["String"]["input"];
execution?: InputMaybe<Scalars["Int"]["input"]>;
Expand Down
2 changes: 2 additions & 0 deletions apps/parsley/src/gql/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,5 +14,6 @@ export {
GET_USER,
PARSLEY_SETTINGS,
PROJECT_FILTERS,
SECRET_FIELDS,
TASK_FILES,
};
5 changes: 5 additions & 0 deletions apps/parsley/src/gql/queries/secret-fields.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
query SecretFields {
spruceConfig {
secretFields
}
}
2 changes: 1 addition & 1 deletion apps/parsley/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/spruce/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/spruce/src/components/Content/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/spruce/src/components/SpruceLoader/index.tsx
Original file line number Diff line number Diff line change
@@ -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<
Expand Down
4 changes: 2 additions & 2 deletions apps/spruce/src/gql/GQLWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Loading

0 comments on commit 5916d0a

Please sign in to comment.