Enable filtering out of expected 400 errors (#14371)

We decided to send 4xx errors to sentry from the FE, except for
ForbiddenErrors.
In some cases, we also need not to send to sentry some 4xx errors, like
user input error, because the error stemming from the user input is
unpredictable (ex: duplicate entry for a record with unicity
constraints) and thus acceptable.

Let's add a isExpected extension on UserInputErrors to introduce a
specific behaviour for them (do not send to sentry).
This commit is contained in:
Marie
2025-09-09 19:30:56 +02:00
committed by GitHub
parent 4ba1bd24f0
commit 5900a171f1
3 changed files with 72 additions and 55 deletions
@@ -156,6 +156,64 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
).flatMap(() => forward(operation));
};
const sendToSentry = ({
graphQLError,
operation,
}: {
graphQLError: GraphQLFormattedError;
operation: Operation;
}) => {
if (isDebugMode === true) {
logDebug(
`[GraphQL error]: Message: ${graphQLError.message}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
}
import('@sentry/react')
.then(({ captureException, withScope }) => {
withScope((scope) => {
const error = new Error(graphQLError.message);
error.name = graphQLError.message;
const fingerPrint: string[] = [];
if (isDefined(graphQLError.extensions)) {
scope.setExtra('extensions', graphQLError.extensions);
if (isDefined(graphQLError.extensions.subCode)) {
fingerPrint.push(graphQLError.extensions.subCode as string);
}
}
if (isDefined(operation.operationName)) {
scope.setExtra('operation', operation.operationName);
const genericOperationName = getGenericOperationName(
operation.operationName,
);
if (isDefined(genericOperationName)) {
fingerPrint.push(genericOperationName);
}
}
if (!isEmpty(fingerPrint)) {
scope.setFingerprint(fingerPrint);
}
captureException(error); // Sentry expects a JS error
});
})
.catch((sentryError) => {
// eslint-disable-next-line no-console
console.error(
'Failed to capture GraphQL error with Sentry:',
sentryError,
);
});
};
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
if (isDefined(graphQLErrors)) {
@@ -179,63 +237,18 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
case 'FORBIDDEN': {
return;
}
case 'USER_INPUT_ERROR': {
if (graphQLError.extensions?.isExpected === true) {
return;
}
sendToSentry({ graphQLError, operation });
return;
}
case 'INTERNAL_SERVER_ERROR': {
return; // already caught in BE
}
default:
if (isDebugMode === true) {
logDebug(
`[GraphQL error]: Message: ${
graphQLError.message
}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
}
import('@sentry/react')
.then(({ captureException, withScope }) => {
withScope((scope) => {
const error = new Error(graphQLError.message);
error.name = graphQLError.message;
const fingerPrint: string[] = [];
if (isDefined(graphQLError.extensions)) {
scope.setExtra('extensions', graphQLError.extensions);
if (isDefined(graphQLError.extensions.subCode)) {
fingerPrint.push(
graphQLError.extensions.subCode as string,
);
}
}
if (isDefined(operation.operationName)) {
scope.setExtra('operation', operation.operationName);
const genericOperationName = getGenericOperationName(
operation.operationName,
);
if (isDefined(genericOperationName)) {
fingerPrint.push(genericOperationName);
}
}
if (!isEmpty(fingerPrint)) {
scope.setFingerprint(fingerPrint);
}
captureException(error); // Sentry expects a JS error
});
})
.catch((sentryError) => {
// eslint-disable-next-line no-console
console.error(
'Failed to capture GraphQL error with Sentry:',
sentryError,
);
});
sendToSentry({ graphQLError, operation });
}
}
}
@@ -54,6 +54,7 @@ export const handleDuplicateKeyError = (
`Duplicate ${columnNames} ${duplicatedValues ? `with value ${duplicatedValues}` : ''}. Please set a unique one.`,
{
userFriendlyMessage: `This ${columnNames.toLowerCase()} ${duplicatedValues ? `with value ${duplicatedValues}` : ''} is already taken. Please choose a different value.`,
isExpected: true,
},
);
}
@@ -173,11 +173,14 @@ export class ForbiddenError extends BaseGraphQLError {
export class UserInputError extends BaseGraphQLError {
constructor(exception: CustomException);
constructor(message: string, extensions?: RestrictedGraphQLErrorExtensions);
constructor(
message: string,
extensions?: RestrictedGraphQLErrorExtensions & { isExpected?: boolean },
);
constructor(
messageOrException: string | CustomException,
extensions?: RestrictedGraphQLErrorExtensions,
extensions?: RestrictedGraphQLErrorExtensions & { isExpected?: boolean },
) {
super(messageOrException, ErrorCode.BAD_USER_INPUT, extensions);
Object.defineProperty(this, 'name', { value: 'UserInputError' });