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:
@ -38,7 +38,7 @@ export const getActivityTargetObjectRecords = ({
|
||||
const activityTargetObjectRecords = targets
|
||||
.map<ActivityTargetWithTargetRecord | undefined>((activityTarget) => {
|
||||
if (!isDefined(activityTarget)) {
|
||||
throw new Error(`Cannot find activity target`);
|
||||
throw new Error('Cannot find activity target');
|
||||
}
|
||||
|
||||
const correspondingObjectMetadataItem = objectMetadataItems.find(
|
||||
|
||||
@ -19,7 +19,8 @@ import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { GraphQLFormattedError } from 'graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
import { ApolloManager } from '../types/apolloManager.interface';
|
||||
@ -160,8 +161,39 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
);
|
||||
}
|
||||
import('@sentry/react')
|
||||
.then(({ captureException }) => {
|
||||
captureException(graphQLError);
|
||||
.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.code)) {
|
||||
fingerPrint.push(
|
||||
graphQLError.extensions.code 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
|
||||
|
||||
@ -32,11 +32,11 @@ export const CommandMenuRecordPage = () => {
|
||||
);
|
||||
|
||||
if (!viewableRecordNameSingular) {
|
||||
throw new Error(`Object name is not defined`);
|
||||
throw new Error('Object name is not defined');
|
||||
}
|
||||
|
||||
if (!viewableRecordId) {
|
||||
throw new Error(`Record id is not defined`);
|
||||
throw new Error('Record id is not defined');
|
||||
}
|
||||
|
||||
const { objectNameSingular, objectRecordId } = useRecordShowPage(
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
// This class is used to group different error messages under the same code for sentry.
|
||||
export class CustomError extends Error {
|
||||
public code?: string;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect';
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { ErrorInfo, ReactNode } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type AppErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
@ -8,16 +10,26 @@ type AppErrorBoundaryProps = {
|
||||
resetOnLocationChange?: boolean;
|
||||
};
|
||||
|
||||
const hasErrorCode = (
|
||||
error: Error | CustomError,
|
||||
): error is CustomError & { code: string } => {
|
||||
return 'code' in error && isDefined(error.code);
|
||||
};
|
||||
|
||||
export const AppErrorBoundary = ({
|
||||
children,
|
||||
FallbackComponent,
|
||||
resetOnLocationChange = true,
|
||||
}: AppErrorBoundaryProps) => {
|
||||
const handleError = async (error: Error, info: ErrorInfo) => {
|
||||
const handleError = async (error: Error | CustomError, info: ErrorInfo) => {
|
||||
try {
|
||||
const { captureException } = await import('@sentry/react');
|
||||
captureException(error, (scope) => {
|
||||
scope.setExtras({ info });
|
||||
|
||||
const fingerprint = hasErrorCode(error) ? error.code : error.message;
|
||||
scope.setFingerprint([fingerprint]);
|
||||
error.name = error.message;
|
||||
return scope;
|
||||
});
|
||||
} catch (sentryError) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IconReload } from 'twenty-ui/display';
|
||||
import { GRAY_SCALE, THEME_DARK } from 'twenty-ui/theme';
|
||||
@ -98,7 +99,6 @@ const StyledIcon = styled(IconReload)`
|
||||
`;
|
||||
|
||||
export const AppRootErrorFallback = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
title = 'Sorry, something went wrong',
|
||||
}: AppRootErrorFallbackProps) => {
|
||||
@ -117,8 +117,10 @@ export const AppRootErrorFallback = ({
|
||||
/>
|
||||
</StyledImageContainer>
|
||||
<StyledEmptyTextContainer>
|
||||
<StyledEmptyTitle>{title}</StyledEmptyTitle>
|
||||
<StyledEmptySubTitle>{error.message}</StyledEmptySubTitle>
|
||||
<StyledEmptyTitle>{t`${title}`}</StyledEmptyTitle>
|
||||
<StyledEmptySubTitle>
|
||||
{t`Please refresh the page.`}
|
||||
</StyledEmptySubTitle>
|
||||
</StyledEmptyTextContainer>
|
||||
<StyledButton onClick={resetErrorBoundary}>
|
||||
<StyledIcon size={16} />
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const hasErrorCode = (
|
||||
error: CustomError | any,
|
||||
): error is CustomError & { code: string } => {
|
||||
return 'code' in error && isDefined(error.code);
|
||||
};
|
||||
|
||||
export const PromiseRejectionEffect = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const handlePromiseRejection = useCallback(
|
||||
(event: PromiseRejectionEvent) => {
|
||||
async (event: PromiseRejectionEvent) => {
|
||||
const error = event.reason;
|
||||
|
||||
// TODO: connect Sentry here
|
||||
if (error instanceof ObjectMetadataItemNotFoundError) {
|
||||
enqueueSnackBar(
|
||||
`Error with custom object that cannot be found : ${event.reason}`,
|
||||
@ -24,6 +32,25 @@ export const PromiseRejectionEffect = () => {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'ApolloError' && !isEmpty(error.graphQLErrors)) {
|
||||
return; // already handled by apolloLink
|
||||
}
|
||||
|
||||
try {
|
||||
const { captureException } = await import('@sentry/react');
|
||||
captureException(error, (scope) => {
|
||||
scope.setExtras({ mechanism: 'onUnhandle' });
|
||||
|
||||
const fingerprint = hasErrorCode(error) ? error.code : error.message;
|
||||
scope.setFingerprint([fingerprint]);
|
||||
error.name = error.message;
|
||||
return scope;
|
||||
});
|
||||
} catch (sentryError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to capture exception with Sentry:', sentryError);
|
||||
}
|
||||
},
|
||||
[enqueueSnackBar],
|
||||
);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@ -6,6 +5,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
@ -30,14 +30,24 @@ export const SentryInitEffect = () => {
|
||||
setIsSentryInitializing(true);
|
||||
|
||||
try {
|
||||
const { init, browserTracingIntegration, replayIntegration } =
|
||||
await import('@sentry/react');
|
||||
const {
|
||||
init,
|
||||
browserTracingIntegration,
|
||||
replayIntegration,
|
||||
globalHandlersIntegration,
|
||||
} = await import('@sentry/react');
|
||||
|
||||
init({
|
||||
environment: sentryConfig?.environment ?? undefined,
|
||||
release: sentryConfig?.release ?? undefined,
|
||||
dsn: sentryConfig?.dsn,
|
||||
integrations: [browserTracingIntegration({}), replayIntegration()],
|
||||
integrations: [
|
||||
browserTracingIntegration({}),
|
||||
replayIntegration(),
|
||||
globalHandlersIntegration({
|
||||
onunhandledrejection: false, // handled in PromiseRejectionEffect
|
||||
}),
|
||||
],
|
||||
tracePropagationTargets: [
|
||||
'localhost:3001',
|
||||
REACT_APP_SERVER_BASE_URL,
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { AppErrorDisplayProps } from '@/error-handler/types/AppErrorDisplayProps';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconRefresh } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderEmptyContainer,
|
||||
@ -6,11 +9,8 @@ import {
|
||||
AnimatedPlaceholderEmptyTextContainer,
|
||||
AnimatedPlaceholderEmptyTitle,
|
||||
} from 'twenty-ui/layout';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconRefresh } from 'twenty-ui/display';
|
||||
|
||||
export const AppErrorDisplay = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
title = 'Sorry, something went wrong',
|
||||
}: AppErrorDisplayProps) => {
|
||||
@ -20,7 +20,7 @@ export const AppErrorDisplay = ({
|
||||
<AnimatedPlaceholderEmptyTextContainer>
|
||||
<AnimatedPlaceholderEmptyTitle>{title}</AnimatedPlaceholderEmptyTitle>
|
||||
<AnimatedPlaceholderEmptySubTitle>
|
||||
{error.message}
|
||||
{t`Please refresh the page.`}
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
<Button
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
|
||||
export const validateAndExtractFolderId = (
|
||||
@ -12,7 +13,11 @@ export const validateAndExtractFolderId = (
|
||||
FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
|
||||
'',
|
||||
);
|
||||
if (!folderId) throw new Error(`Invalid folder header ID: ${droppableId}`);
|
||||
if (!folderId)
|
||||
throw new CustomError(
|
||||
`Invalid folder header ID: ${droppableId}`,
|
||||
'INVALID_FOLDER_HEADER_ID',
|
||||
);
|
||||
return folderId;
|
||||
}
|
||||
|
||||
@ -21,9 +26,16 @@ export const validateAndExtractFolderId = (
|
||||
FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX,
|
||||
'',
|
||||
);
|
||||
if (!folderId) throw new Error(`Invalid folder ID: ${droppableId}`);
|
||||
if (!folderId)
|
||||
throw new CustomError(
|
||||
`Invalid folder ID: ${droppableId}`,
|
||||
'INVALID_FOLDER_ID',
|
||||
);
|
||||
return folderId;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid droppable ID format: ${droppableId}`);
|
||||
throw new CustomError(
|
||||
`Invalid droppable ID format: ${droppableId}`,
|
||||
'INVALID_DROPPABLE_ID_FORMAT',
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const FIELD_METADATA_ITEM_NOT_FOUND_ERROR_CODE =
|
||||
'FIELD_METADATA_ITEM_NOT_FOUND';
|
||||
|
||||
export const useFieldMetadataItemById = (fieldMetadataId: string) => {
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
|
||||
@ -10,7 +14,10 @@ export const useFieldMetadataItemById = (fieldMetadataId: string) => {
|
||||
.find((field) => field.id === fieldMetadataId);
|
||||
|
||||
if (!isDefined(fieldMetadataItem)) {
|
||||
throw new Error(`Field metadata item not found for id ${fieldMetadataId}`);
|
||||
throw new CustomError(
|
||||
`Field metadata item not found for id ${fieldMetadataId}`,
|
||||
FIELD_METADATA_ITEM_NOT_FOUND_ERROR_CODE,
|
||||
);
|
||||
}
|
||||
|
||||
return { fieldMetadataItem };
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@ -15,7 +16,10 @@ export const useObjectMetadataItemById = ({
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
throw new Error(`Object metadata item not found for id ${objectId}`);
|
||||
throw new CustomError(
|
||||
`Object metadata item not found for id ${objectId}`,
|
||||
'OBJECT_METADATA_ITEM_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,13 +2,14 @@ import { AppPath } from '@/types/AppPath';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import {
|
||||
CalendarChannelVisibility,
|
||||
MessageChannelVisibility,
|
||||
useGenerateTransientTokenMutation,
|
||||
} from '~/generated/graphql';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
const getProviderUrl = (provider: ConnectedAccountProvider) => {
|
||||
switch (provider) {
|
||||
@ -17,7 +18,10 @@ const getProviderUrl = (provider: ConnectedAccountProvider) => {
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return 'microsoft-apis';
|
||||
default:
|
||||
throw new Error(`Provider ${provider} is not supported`);
|
||||
throw new CustomError(
|
||||
`Provider ${provider} is not supported`,
|
||||
'UNSUPPORTED_PROVIDER',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
@ -186,6 +187,6 @@ export const ConfigVariableDatabaseInput = ({
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported type: ${type}`);
|
||||
throw new CustomError(`Unsupported type: ${type}`, 'UNSUPPORTED_TYPE');
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
@ -24,6 +25,6 @@ export const useSourceContent = (source: ConfigSource) => {
|
||||
color: theme.font.color.tertiary,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown source: ${source}`);
|
||||
throw new CustomError(`Unknown source: ${source}`, 'UNKNOWN_SOURCE');
|
||||
}
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import isEmpty from 'lodash.isempty';
|
||||
import pickBy from 'lodash.pickby';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import {
|
||||
settingsIntegrationPostgreSQLConnectionFormSchema,
|
||||
settingsIntegrationStripeConnectionFormSchema,
|
||||
@ -18,7 +19,10 @@ export const getEditionSchemaForForm = (databaseKey: string) => {
|
||||
case 'stripe':
|
||||
return settingsIntegrationStripeConnectionFormSchema;
|
||||
default:
|
||||
throw new Error(`No schema found for database key: ${databaseKey}`);
|
||||
throw new CustomError(
|
||||
`No schema found for database key: ${databaseKey}`,
|
||||
'NO_SCHEMA_FOUND',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -91,6 +95,9 @@ export const formatValuesForUpdate = ({
|
||||
label: formValues.label,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Cannot format values for database key: ${databaseKey}`);
|
||||
throw new CustomError(
|
||||
`Cannot format values for database key: ${databaseKey}`,
|
||||
'CANNOT_FORMAT_VALUES',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playground/constants/SettingsPlaygroundFormSchemaSelectOptions';
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
@ -66,7 +67,10 @@ export const PlaygroundSetupForm = () => {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
throw new CustomError(
|
||||
`HTTP error! status: ${response.status}`,
|
||||
'HTTP_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
const openAPIReference = await response.json();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { WorkflowActionType } from '@/workflow/types/Workflow';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
@ -65,6 +66,9 @@ export const shouldDisplayFormField = ({
|
||||
fieldMetadataItem.isActive
|
||||
);
|
||||
default:
|
||||
throw new Error(`Action "${actionType}" is not supported`);
|
||||
throw new CustomError(
|
||||
`Action "${actionType}" is not supported`,
|
||||
'UNSUPPORTED_ACTION_TYPE',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import { Decorator } from '@storybook/react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||
@ -103,7 +104,10 @@ export const getFieldDecorator =
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
throw new Error(`Object ${objectNameSingular} not found`);
|
||||
throw new CustomError(
|
||||
`Object ${objectNameSingular} not found`,
|
||||
'OBJECT_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(fieldMetadataItem)) {
|
||||
|
||||
@ -7,6 +7,7 @@ import moize from 'moize';
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
import { logError } from './logError';
|
||||
|
||||
export const DEFAULT_DATE_LOCALE = 'en-EN';
|
||||
@ -17,7 +18,10 @@ export const parseDate = (dateToParse: Date | string | number) => {
|
||||
let formattedDate: DateTime | null = null;
|
||||
|
||||
if (!dateToParse) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
throw new CustomError(
|
||||
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
||||
'INVALID_DATE_FORMAT',
|
||||
);
|
||||
} else if (isString(dateToParse)) {
|
||||
formattedDate = DateTime.fromISO(dateToParse);
|
||||
} else if (isDate(dateToParse)) {
|
||||
@ -27,11 +31,17 @@ export const parseDate = (dateToParse: Date | string | number) => {
|
||||
}
|
||||
|
||||
if (!formattedDate) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
throw new CustomError(
|
||||
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
||||
'INVALID_DATE_FORMAT',
|
||||
);
|
||||
}
|
||||
|
||||
if (!formattedDate.isValid) {
|
||||
throw new Error(`Invalid date passed to formatPastDate: "${dateToParse}"`);
|
||||
throw new CustomError(
|
||||
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
||||
'INVALID_DATE_FORMAT',
|
||||
);
|
||||
}
|
||||
|
||||
return formattedDate.setLocale(DEFAULT_DATE_LOCALE);
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
getGenericOperationName,
|
||||
getHumanReadableNameFromCode,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
|
||||
|
||||
@ -63,20 +67,15 @@ export class ExceptionHandlerSentryDriver
|
||||
) {
|
||||
scope.setTag('customExceptionCode', exception.code);
|
||||
scope.setFingerprint([exception.code]);
|
||||
exception.name = exception.code
|
||||
.split('_')
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0)?.toUpperCase() + word.slice(1)?.toLowerCase(),
|
||||
)
|
||||
.join(' ');
|
||||
exception.name = getHumanReadableNameFromCode(exception.code);
|
||||
}
|
||||
|
||||
if (exception instanceof PostgresException) {
|
||||
scope.setTag('postgresSqlErrorCode', exception.code);
|
||||
const fingerPrint = [exception.code];
|
||||
const genericOperationName = // truncates to first word: FindOnePerson -> Find, AggregateCompanies -> Aggregate, ...
|
||||
options?.operation?.name?.match(/^[A-Z][a-z]*/)?.[0];
|
||||
const genericOperationName = getGenericOperationName(
|
||||
options?.operation?.name,
|
||||
);
|
||||
|
||||
if (isDefined(genericOperationName)) {
|
||||
fingerPrint.push(genericOperationName);
|
||||
|
||||
@ -54,7 +54,7 @@ export class BaseGraphQLError extends GraphQLError {
|
||||
|
||||
// if no name provided, use the default. defineProperty ensures that it stays non-enumerable
|
||||
if (!this.name) {
|
||||
Object.defineProperty(this, 'name', { value: 'ApolloError' });
|
||||
Object.defineProperty(this, 'name', { value: 'GraphQLError' });
|
||||
}
|
||||
|
||||
if (extensions?.extensions) {
|
||||
|
||||
@ -16,6 +16,8 @@ export {
|
||||
} from './image/getLogoUrlFromDomainName';
|
||||
export { parseJson } from './parseJson';
|
||||
export { removeUndefinedFields } from './removeUndefinedFields';
|
||||
export { getGenericOperationName } from './sentry/getGenericOperationName';
|
||||
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
|
||||
export { capitalize } from './strings/capitalize';
|
||||
export { absoluteUrlSchema } from './url/absoluteUrlSchema';
|
||||
export { buildSignedPath } from './url/buildSignedPath';
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
// truncates to first word: FindOnePerson -> Find, AggregateCompanies -> Aggregate, ...
|
||||
export const getGenericOperationName = (name?: string) => {
|
||||
return name?.match(/^[A-Z][a-z]*/)?.[0];
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
export const getHumanReadableNameFromCode = (code: string) => {
|
||||
return code
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0)?.toUpperCase() + word.slice(1)?.toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
Reference in New Issue
Block a user