From 5900a171f115856da5b5fe52faf1499908b2c960 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Tue, 9 Sep 2025 19:30:56 +0200 Subject: [PATCH] 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). --- .../modules/apollo/services/apollo.factory.ts | 119 ++++++++++-------- .../utils/handle-duplicate-key-error.util.ts | 1 + .../graphql/utils/graphql-errors.util.ts | 7 +- 3 files changed, 72 insertions(+), 55 deletions(-) diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index f4a4353a6d8..361322ca2bb 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -156,6 +156,64 @@ export class ApolloFactory implements ApolloManager { ).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 implements ApolloManager { 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 }); } } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts index 59f504f497c..98cc842bc38 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts @@ -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, }, ); } diff --git a/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts b/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts index 5ca324b187e..20ccbac8690 100644 --- a/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts +++ b/packages/twenty-server/src/engine/core-modules/graphql/utils/graphql-errors.util.ts @@ -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' });