Improve FE error handling (#12864)

This PR aims at improving readability in sentry and user experience with
runtime errors.

**GraphQL errors (and ApolloError)**
1. In sentry we have a lot of "Object captured as exception with keys:
extensions, message" errors (2k over the last 90d), on which we have
zero information. This is because in apollo-factory we were passing on
GraphQL errors to sentry directly why sentry expects the structure of a
JS Error. We are now changing that, rebuilding an Error object and
attempting to help grouping by creating a fingerPrint based on error
code and truncated operationName (same as we do in the back for 500
graphql errors).
2. In sentry we have a lot of ApolloError, who actually correspond to
errors that should not be logged in sentry (Forbidden errors such as
"Email is not verified"), or errors that are already tracked by back-end
(Postgres errors such as "column xxx does not exist"). This is because
ApolloErrors become unhandled rejections errors if they are not caught
and automatically sent to sentry through the basic config. To change
that we are now filtering out ApolloErrors created from GraphQL Errors
before sending error to sentry:
<img width="524" alt="image"
src="https://github.com/user-attachments/assets/02974829-26d9-4a9e-8c4c-cfe70155e4ab"
/>

**Runtime errors**
4. Runtime errors were all caught by sentry with the name "Error",
making them not easy to differentiate on sentry (they were not grouped
together but all appeared in the list as "Error"). We are replacing the
"Error" name with the error message, or the error code if present. We
are introducing a CustomError class that allows errors whose message
contain dynamic text (an id for instance) to be identified on sentry
with a common code. _(TODO: if this approach is validated then I have
yet to replace Error with dynamic error messages with CustomError)_
5. Runtime error messages contain technical details that do not mean
anything to users (for instance, "Invalid folder ID: ${droppableId}",
"ObjectMetadataItem not found", etc.). Let's replace them with "Please
refresh the page." to users and keep the message error for sentry and
our dev experience (they will still show in the console as uncaught
errors).
This commit is contained in:
Marie
2025-06-26 17:54:12 +02:00
committed by GitHub
parent ada7933f9b
commit 2d5312276c
36 changed files with 272 additions and 62 deletions

View File

@ -1,5 +1,6 @@
import { useApolloClient } from '@apollo/client';
import { CustomError } from '@/error-handler/CustomError';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
@ -33,8 +34,9 @@ export const useAttachRelatedRecordFromRecord = ({
fieldOnObject?.relation?.targetObjectMetadata.nameSingular;
if (!relatedRecordObjectNameSingular) {
throw new Error(
throw new CustomError(
`Could not find record related to ${recordObjectNameSingular}`,
'RELATED_RECORD_NOT_FOUND',
);
}
const { objectMetadataItem: relatedObjectMetadataItem } =
@ -46,7 +48,10 @@ export const useAttachRelatedRecordFromRecord = ({
fieldOnObject?.relation?.targetFieldMetadata.name;
if (!fieldOnRelatedObject) {
throw new Error(`Missing target field for ${fieldNameOnRecordObject}`);
throw new CustomError(
`Missing target field for ${fieldNameOnRecordObject}`,
'MISSING_TARGET_FIELD',
);
}
const { updateOneRecord } = useUpdateOneRecord({

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
@ -36,6 +37,9 @@ export const getAggregateOperationLabel = (
case AggregateOperations.COUNT_FALSE:
return t`Count false`;
default:
throw new Error(`Unknown aggregate operation: ${operation}`);
throw new CustomError(
`Unknown aggregate operation: ${operation}`,
'UNKNOWN_AGGREGATE_OPERATION',
);
}
};

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations';
import { ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
@ -34,6 +35,9 @@ export const getAggregateOperationShortLabel = (
case AggregateOperations.COUNT_FALSE:
return msg`False`;
default:
throw new Error(`Unknown aggregate operation: ${operation}`);
throw new CustomError(
`Unknown aggregate operation: ${operation}`,
'UNKNOWN_AGGREGATE_OPERATION',
);
}
};

View File

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { CustomError } from '@/error-handler/CustomError';
import {
MultiItemBaseInput,
MultiItemBaseInputProps,
@ -124,7 +125,10 @@ export const MultiItemFieldInput = <T,>({
setInputValue(item);
break;
default:
throw new Error(`Unsupported field type: ${fieldMetadataType}`);
throw new CustomError(
`Unsupported field type: ${fieldMetadataType}`,
'UNSUPPORTED_FIELD_TYPE',
);
}
setItemToEditIndex(index);

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
@ -74,5 +75,8 @@ export const computeDraftValueFromString = <FieldValue>({
} as FieldInputDraftValue<FieldValue>;
}
throw new Error(`Record field type not supported : ${fieldDefinition.type}}`);
throw new CustomError(
`Record field type not supported : ${fieldDefinition.type}}`,
'RECORD_FIELD_TYPE_NOT_SUPPORTED',
);
};

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
@ -64,5 +65,8 @@ export const computeEmptyDraftValue = <FieldValue>({
} as FieldInputDraftValue<FieldValue>;
}
throw new Error(`Record field type not supported : ${fieldDefinition.type}}`);
throw new CustomError(
`Record field type not supported : ${fieldDefinition.type}}`,
'RECORD_FIELD_TYPE_NOT_SUPPORTED',
);
};

View File

@ -4,6 +4,7 @@ import {
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { CustomError } from '@/error-handler/CustomError';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isNonEmptyString } from '@sniptt/guards';
@ -52,7 +53,10 @@ export const computeEmptyGqlOperationFilterForEmails = ({
};
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}

View File

@ -4,6 +4,7 @@ import {
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { CustomError } from '@/error-handler/CustomError';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isNonEmptyString } from '@sniptt/guards';
@ -68,7 +69,10 @@ export const computeEmptyGqlOperationFilterForLinks = ({
};
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}

View File

@ -4,6 +4,7 @@ import {
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { CustomError } from '@/error-handler/CustomError';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
@ -80,13 +81,17 @@ export const computeGqlOperationFilterForEmails = ({
],
};
default:
throw new Error(
throw new CustomError(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
}
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { LinksFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
@ -83,7 +84,10 @@ export const computeGqlOperationFilterForLinks = ({
}
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}

View File

@ -1,3 +1,4 @@
import { CustomError } from '@/error-handler/CustomError';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import {
ActorFilter,
@ -384,7 +385,10 @@ export const getEmptyRecordGqlOperationFilter = ({
});
break;
default:
throw new Error(`Unsupported empty filter type ${filterType}`);
throw new CustomError(
`Unsupported empty filter type ${filterType}`,
'UNSUPPORTED_EMPTY_FILTER_TYPE',
);
}
switch (operand) {
@ -395,6 +399,9 @@ export const getEmptyRecordGqlOperationFilter = ({
not: emptyRecordFilter,
};
default:
throw new Error(`Unknown operand ${operand} for ${filterType} filter`);
throw new CustomError(
`Unknown operand ${operand} for ${filterType} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
}
};