diff --git a/src/components/ErrorHandling/Sentry.tsx b/src/components/ErrorHandling/Sentry.tsx index 09defe0d5a..b26f48272a 100644 --- a/src/components/ErrorHandling/Sentry.tsx +++ b/src/components/ErrorHandling/Sentry.tsx @@ -4,9 +4,11 @@ import { getCurrentHub, init, Replay, + setTags, withScope, } from "@sentry/react"; import type { Scope, SeverityLevel } from "@sentry/react"; +import type { Context, Primitive } from "@sentry/types"; import { environmentVariables } from "utils"; import ErrorFallback from "./ErrorFallback"; @@ -38,13 +40,33 @@ const initializeSentry = () => { const isInitialized = () => !!getCurrentHub().getClient(); -const sendError = ( - err: Error, - severity: SeverityLevel, - metadata?: { [key: string]: any }, -) => { +export type ErrorInput = { + err: Error; + fingerprint?: string[]; + context?: Context; + severity: SeverityLevel; + tags?: { [key: string]: Primitive }; +}; + +const sendError = ({ + context, + err, + fingerprint, + severity, + tags, +}: ErrorInput) => { withScope((scope) => { - setScope(scope, { level: severity, context: metadata }); + setScope(scope, { level: severity, context }); + + if (fingerprint) { + // A custom fingerprint allows for more intelligent grouping + scope.setFingerprint(fingerprint); + } + + if (tags) { + // Apply tags, which are a searchable/filterable property + setTags(tags); + } captureException(err); }); @@ -52,7 +74,7 @@ const sendError = ( type ScopeOptions = { level?: SeverityLevel; - context?: { [key: string]: any }; + context?: Context; }; const setScope = (scope: Scope, { context, level }: ScopeOptions = {}) => { diff --git a/src/gql/GQLWrapper.tsx b/src/gql/GQLWrapper.tsx index 4618249005..6c2e5e857e 100644 --- a/src/gql/GQLWrapper.tsx +++ b/src/gql/GQLWrapper.tsx @@ -144,10 +144,17 @@ const authLink = (logout: () => void): ApolloLink => 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), { - gqlErr, - operationName: operation.operationName, - variables: operation.variables, + fingerprint, + tags: { operationName: operation.operationName }, + context: { + gqlErr, + variables: operation.variables, + }, }).warning(); }); } diff --git a/src/utils/errorReporting.test.ts b/src/utils/errorReporting.test.ts index be8688bf57..1e8eb1cf89 100644 --- a/src/utils/errorReporting.test.ts +++ b/src/utils/errorReporting.test.ts @@ -34,7 +34,7 @@ describe("error reporting", () => { expect(Sentry.captureException).not.toHaveBeenCalled(); }); - it("should report errors to Sentry when in production", () => { + it("reports errors to Sentry when in production", () => { mockEnv("NODE_ENV", "production"); jest.spyOn(Sentry, "captureException").mockImplementation(jest.fn()); @@ -46,7 +46,7 @@ describe("error reporting", () => { expect(Sentry.captureException).toHaveBeenCalledWith(err); }); - it("supports metadata field", () => { + it("supports context field", () => { mockEnv("NODE_ENV", "production"); jest.spyOn(Sentry, "captureException").mockImplementation(jest.fn()); const err = { @@ -54,13 +54,32 @@ describe("error reporting", () => { name: "Error Name", }; - const metadata = { customField: "foo" }; - const result = reportError(err, metadata); + const context = { anything: "foo" }; + const result = reportError(err, { context }); result.severe(); expect(Sentry.captureException).toHaveBeenCalledWith(err); result.warning(); expect(Sentry.captureException).toHaveBeenCalledWith(err); }); + + it("supports tags", () => { + mockEnv("NODE_ENV", "production"); + jest.spyOn(Sentry, "captureException").mockImplementation(jest.fn()); + jest.spyOn(Sentry, "setTags").mockImplementation(jest.fn()); + const err = { + message: "GraphQL Error", + name: "Error Name", + }; + + const tags = { spruce: "true" }; + const result = reportError(err, { tags }); + result.severe(); + expect(Sentry.captureException).toHaveBeenCalledWith(err); + expect(Sentry.setTags).toHaveBeenCalledWith(tags); + result.warning(); + expect(Sentry.captureException).toHaveBeenCalledWith(err); + expect(Sentry.setTags).toHaveBeenCalledWith(tags); + }); }); describe("breadcrumbs", () => { diff --git a/src/utils/errorReporting.ts b/src/utils/errorReporting.ts index c13c580a0b..5e20e345d5 100644 --- a/src/utils/errorReporting.ts +++ b/src/utils/errorReporting.ts @@ -1,5 +1,8 @@ import { addBreadcrumb, Breadcrumb } from "@sentry/react"; -import { sendError as sentrySendError } from "components/ErrorHandling/Sentry"; +import { + ErrorInput, + sendError as sentrySendError, +} from "components/ErrorHandling/Sentry"; import { isProductionBuild } from "./environmentVariables"; interface reportErrorResult { @@ -7,27 +10,45 @@ interface reportErrorResult { warning: () => void; } +type ErrorMetadata = { + fingerprint?: ErrorInput["fingerprint"]; + tags?: ErrorInput["tags"]; + context?: ErrorInput["context"]; +}; + const reportError = ( err: Error, - metadata?: { [key: string]: any }, + { context, fingerprint, tags }: ErrorMetadata = {}, ): reportErrorResult => { if (!isProductionBuild()) { return { severe: () => { - console.error({ err, severity: "severe", metadata }); + console.error({ err, severity: "severe", context, fingerprint, tags }); }, warning: () => { - console.error({ err, severity: "warning", metadata }); + console.error({ err, severity: "warning", context, fingerprint, tags }); }, }; } return { severe: () => { - sentrySendError(err, "error", metadata); + sentrySendError({ + context, + err, + fingerprint, + severity: "error", + tags, + }); }, warning: () => { - sentrySendError(err, "warning", metadata); + sentrySendError({ + context, + err, + fingerprint, + severity: "warning", + tags, + }); }, }; };