Improve sentry filtering and grouping (#12071)

Follow-up on https://github.com/twentyhq/twenty/pull/12007

In this PR

- adding a filter on HttpExceptionHandlerService to filter out 4xx
errors from driver handling (as we do for graphQL errors: see
useGraphQLErrorHandler hook - only filteredIssues are sent to`
exceptionHandlerService.captureExceptions()`.)
- grouping together more missing metadata issues
- attempting to use error codes as issues names in sentry to improve UI;
for now it says "Error" all the time
This commit is contained in:
Marie
2025-05-16 11:35:48 +02:00
committed by GitHub
parent 4d303a61d1
commit dc4bcc3049
19 changed files with 145 additions and 120 deletions

View File

@ -2,7 +2,7 @@ import { CommandFactory } from 'nest-commander';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util';
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
import { CommandModule } from './command.module';
@ -10,11 +10,9 @@ async function bootstrap() {
const errorHandler = (err: Error) => {
loggerService.error(err?.message, err?.name);
if (shouldFilterException(err)) {
return;
if (shouldCaptureException(err)) {
exceptionHandlerService.captureExceptions([err]);
}
exceptionHandlerService.captureExceptions([err]);
};
const app = await CommandFactory.createWithoutRunning(CommandModule, {

View File

@ -22,6 +22,5 @@ export enum GraphqlQueryRunnerExceptionCode {
RELATION_SETTINGS_NOT_FOUND = 'RELATION_SETTINGS_NOT_FOUND',
RELATION_TARGET_OBJECT_METADATA_NOT_FOUND = 'RELATION_TARGET_OBJECT_METADATA_NOT_FOUND',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
OBJECT_METADATA_COLLECTION_NOT_FOUND = 'OBJECT_METADATA_COLLECTION_NOT_FOUND',
INVALID_POST_HOOK_PAYLOAD = 'INVALID_POST_HOOK_PAYLOAD',
}

View File

@ -27,7 +27,6 @@ export const graphqlQueryRunnerExceptionHandler = (
throw new NotFoundError(error.message);
case GraphqlQueryRunnerExceptionCode.RELATION_SETTINGS_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_COLLECTION_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.INVALID_POST_HOOK_PAYLOAD:
throw error;
default: {

View File

@ -5,10 +5,6 @@ import { GraphQLSchema, printSchema } from 'graphql';
import { gql } from 'graphql-tag';
import { isDefined } from 'twenty-shared/utils';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-explorer.service';
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
@ -18,6 +14,10 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import {
WorkspaceMetadataVersionException,
@ -86,9 +86,9 @@ export class WorkspaceSchemaFactory {
}
if (!objectMetadataMaps) {
throw new GraphqlQueryRunnerException(
throw new WorkspaceMetadataCacheException(
'Object metadata collection not found',
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_COLLECTION_NOT_FOUND,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_COLLECTION_NOT_FOUND,
);
}

View File

@ -20,18 +20,6 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
case AuthExceptionCode.INVALID_INPUT:
throw new UserInputError(exception.message);
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
throw new ForbiddenError(exception.message);
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
throw new ForbiddenError(exception.message, {
subCode: AuthExceptionCode.EMAIL_NOT_VERIFIED,
});
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
throw new AuthenticationError(exception.message);
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
case AuthExceptionCode.INSUFFICIENT_SCOPES:
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
case AuthExceptionCode.SSO_AUTH_FAILED:
@ -40,6 +28,18 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
throw new ForbiddenError(exception.message);
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
case AuthExceptionCode.INVALID_DATA:
throw new ForbiddenError(exception.message, {
subCode: AuthExceptionCode.EMAIL_NOT_VERIFIED,
});
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
throw new AuthenticationError(exception.message);
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw exception;
default: {
const _exhaustiveCheck: never = exception.code;

View File

@ -0,0 +1,37 @@
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
export const getAuthExceptionRestStatus = (exception: AuthException) => {
switch (exception.code) {
case AuthExceptionCode.CLIENT_NOT_FOUND:
return 404;
case AuthExceptionCode.INVALID_INPUT:
return 400;
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
case AuthExceptionCode.INSUFFICIENT_SCOPES:
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
case AuthExceptionCode.SSO_AUTH_FAILED:
case AuthExceptionCode.USE_SSO_AUTH:
case AuthExceptionCode.SIGNUP_DISABLED:
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
return 403;
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
return 401;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
return 500;
default: {
const _exhaustiveCheck: never = exception.code;
return 500;
}
}
};

View File

@ -24,8 +24,8 @@ import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manag
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleException } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { handleException } from 'src/engine/utils/global-exception-handler.util';
@Controller()
@UseFilters(AuthRestApiExceptionFilter)
@ -43,13 +43,13 @@ export class CloudflareController {
@UseGuards(CloudflareSecretMatchGuard)
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
if (!req.body?.data?.data?.hostname) {
handleException(
new DomainManagerException(
handleException({
exception: new DomainManagerException(
'Hostname missing',
DomainManagerExceptionCode.INVALID_INPUT_DATA,
),
this.exceptionHandlerService,
);
exceptionHandlerService: this.exceptionHandlerService,
});
return res.status(200).send();
}

View File

@ -18,6 +18,7 @@ export class ExceptionHandlerSentryDriver
Sentry.withScope((scope) => {
if (options?.operation) {
scope.setExtra('operation', options.operation.name);
scope.setExtra('operationType', options.operation.type);
}
if (options?.document) {
@ -57,6 +58,13 @@ export class ExceptionHandlerSentryDriver
if (exception instanceof CustomException) {
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(' ');
}
const eventId = Sentry.captureException(exception, {

View File

@ -7,19 +7,9 @@ import { ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/
import { ExceptionHandlerWorkspace } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleException } from 'src/engine/utils/global-exception-handler.util';
import { CustomException } from 'src/utils/custom-exception';
export const handleException = (
exception: CustomException,
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
): CustomException => {
exceptionHandlerService.captureExceptions([exception], { user, workspace });
return exception;
};
interface RequestAndParams {
request: Request | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -51,9 +41,16 @@ export class HttpExceptionHandlerService {
workspace = { ...workspace, id: params.workspaceId };
if (params?.userId) user = { ...user, id: params.userId };
handleException(exception, this.exceptionHandlerService, user, workspace);
const statusCode = errorCode || 500;
handleException({
exception,
exceptionHandlerService: this.exceptionHandlerService,
user,
workspace,
statusCode,
});
return response.status(statusCode).send({
statusCode,
error: exception.name || 'Bad Request',

View File

@ -11,7 +11,7 @@ import { GraphQLContext } from 'src/engine/api/graphql/graphql-config/interfaces
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { generateGraphQLErrorFromError } from 'src/engine/core-modules/graphql/utils/generate-graphql-error-from-error.util';
import { BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { shouldCaptureException } from 'src/engine/core-modules/graphql/utils/should-capture-exception.util';
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
const DEFAULT_EVENT_ID_KEY = 'exceptionEventId';
const SCHEMA_VERSION_HEADER = 'x-schema-version';

View File

@ -1,36 +0,0 @@
import { HttpException } from '@nestjs/common';
import {
BaseGraphQLError,
ErrorCode,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const graphQLErrorCodesToFilterOut = [
ErrorCode.GRAPHQL_VALIDATION_FAILED,
ErrorCode.UNAUTHENTICATED,
ErrorCode.FORBIDDEN,
ErrorCode.NOT_FOUND,
ErrorCode.METHOD_NOT_ALLOWED,
ErrorCode.TIMEOUT,
ErrorCode.CONFLICT,
ErrorCode.BAD_USER_INPUT,
];
export const shouldCaptureException = (exception: Error): boolean => {
if (
exception instanceof BaseGraphQLError &&
graphQLErrorCodesToFilterOut.includes(exception?.extensions?.code)
) {
return false;
}
if (
exception instanceof HttpException &&
exception.getStatus() >= 400 &&
exception.getStatus() < 500
) {
return false;
}
return true;
};

View File

@ -5,21 +5,21 @@ import {
ModuleRef,
createContextId,
} from '@nestjs/core';
import { Module } from '@nestjs/core/injector/module';
import { Injector } from '@nestjs/core/injector/injector';
import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
import { Module } from '@nestjs/core/injector/module';
import { MessageQueueWorkerOptions } from 'src/engine/core-modules/message-queue/interfaces/message-queue-worker-options.interface';
import {
MessageQueueJob,
MessageQueueJobData,
} from 'src/engine/core-modules/message-queue/interfaces/message-queue-job.interface';
import { MessageQueueWorkerOptions } from 'src/engine/core-modules/message-queue/interfaces/message-queue-worker-options.interface';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { MessageQueueMetadataAccessor } from 'src/engine/core-modules/message-queue/message-queue-metadata.accessor';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util';
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
interface ProcessorGroup {
instance: object;
@ -207,7 +207,7 @@ export class MessageQueueExplorer implements OnModuleInit {
// @ts-expect-error legacy noImplicitAny
await instance[processMethodName].call(instance, job.data);
} catch (err) {
if (!shouldFilterException(err)) {
if (shouldCaptureException(err)) {
this.exceptionHandlerService.captureExceptions([err]);
}
throw err;

View File

@ -8,4 +8,5 @@ export class WorkspaceMetadataCacheException extends CustomException {
export enum WorkspaceMetadataCacheExceptionCode {
OBJECT_METADATA_MAP_NOT_FOUND = 'Object Metadata map not found',
OBJECT_METADATA_COLLECTION_NOT_FOUND = 'Object Metadata collection not found',
}

View File

@ -3,9 +3,10 @@ import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@ -53,11 +54,15 @@ export class MiddlewareService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public writeRestResponseOnExceptionCaught(res: Response, error: any) {
// capture and handle custom exceptions
handleException(error as CustomException, this.exceptionHandlerService);
const statusCode = this.getStatus(error);
// capture and handle custom exceptions
handleException({
exception: error as CustomException,
exceptionHandlerService: this.exceptionHandlerService,
statusCode,
});
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.write(
JSON.stringify({
@ -158,13 +163,8 @@ export class MiddlewareService {
return error.status;
}
if (error instanceof CustomException) {
switch (error.code) {
case AuthExceptionCode.UNAUTHENTICATED:
return 401;
default:
return 400;
}
if (error instanceof AuthException) {
return getAuthExceptionRestStatus(error);
}
return 500;

View File

@ -7,9 +7,7 @@ export class TwentyORMException extends CustomException {
}
export enum TwentyORMExceptionCode {
METADATA_VERSION_NOT_FOUND = 'METADATA_VERSION_NOT_FOUND',
METADATA_VERSION_MISMATCH = 'METADATA_VERSION_MISMATCH',
METADATA_COLLECTION_NOT_FOUND = 'METADATA_COLLECTION_NOT_FOUND',
WORKSPACE_SCHEMA_NOT_FOUND = 'WORKSPACE_SCHEMA_NOT_FOUND',
ROLES_PERMISSIONS_VERSION_NOT_FOUND = 'ROLES_PERMISSIONS_VERSION_NOT_FOUND',
FEATURE_FLAG_MAP_VERSION_NOT_FOUND = 'FEATURE_FLAG_MAP_VERSION_NOT_FOUND',

View File

@ -10,6 +10,10 @@ import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interface
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service';
import {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import {
WorkspaceMetadataVersionException,
@ -120,9 +124,9 @@ export class WorkspaceDatasourceFactory {
);
if (!cachedObjectMetadataMaps) {
throw new TwentyORMException(
throw new WorkspaceMetadataCacheException(
`Object metadata collection not found for workspace ${workspaceId}`,
TwentyORMExceptionCode.METADATA_COLLECTION_NOT_FOUND,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_COLLECTION_NOT_FOUND,
);
}
@ -329,7 +333,7 @@ export class WorkspaceDatasourceFactory {
if (!isDefined(latestWorkspaceMetadataVersion)) {
if (shouldFailIfMetadataNotFound) {
throw new WorkspaceMetadataVersionException(
`Metadata version not found for workspace ${workspaceId}`,
`Metadata version not found while fetching datasource for workspace ${workspaceId}`,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
} else {
@ -345,9 +349,9 @@ export class WorkspaceDatasourceFactory {
}
if (!isDefined(latestWorkspaceMetadataVersion)) {
throw new TwentyORMException(
throw new WorkspaceMetadataVersionException(
`Metadata version not found after recompute`,
TwentyORMExceptionCode.METADATA_VERSION_NOT_FOUND,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
}

View File

@ -18,6 +18,7 @@ import {
TimeoutError,
ValidationError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { CustomException } from 'src/utils/custom-exception';
const graphQLPredefinedExceptions = {
400: ValidationError,
@ -46,44 +47,63 @@ export const handleExceptionAndConvertToGraphQLError = (
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
): BaseGraphQLError => {
handleException(exception, exceptionHandlerService, user, workspace);
handleException({
exception,
exceptionHandlerService,
user,
workspace,
});
return convertExceptionToGraphQLError(exception);
};
export const shouldFilterException = (exception: Error): boolean => {
export const shouldCaptureException = (
exception: Error,
statusCode?: number,
): boolean => {
if (
exception instanceof GraphQLError &&
(exception?.extensions?.http?.status ?? 500) < 500
) {
return true;
return false;
}
if (
exception instanceof BaseGraphQLError &&
graphQLErrorCodesToFilter.includes(exception?.extensions?.code)
) {
return true;
return false;
}
if (exception instanceof HttpException && exception.getStatus() < 500) {
return true;
return false;
}
return false;
if (statusCode && statusCode < 500) {
return false;
}
return true;
};
export const handleException = (
exception: Error,
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
): void => {
if (shouldFilterException(exception)) {
return;
export const handleException = <T extends Error | CustomException>({
exception,
exceptionHandlerService,
user,
workspace,
statusCode,
}: {
exception: T;
exceptionHandlerService: ExceptionHandlerService;
user?: ExceptionHandlerUser;
workspace?: ExceptionHandlerWorkspace;
statusCode?: number;
}): T => {
if (shouldCaptureException(exception, statusCode)) {
exceptionHandlerService.captureExceptions([exception], { user, workspace });
}
exceptionHandlerService.captureExceptions([exception], { user, workspace });
return exception;
};
export const convertExceptionToGraphQLError = (

View File

@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { shouldFilterException } from 'src/engine/utils/global-exception-handler.util';
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
import 'src/instrument';
import { QueueWorkerModule } from 'src/queue-worker/queue-worker.module';
@ -23,7 +23,7 @@ async function bootstrap() {
} catch (err) {
loggerService?.error(err?.message, err?.name);
if (!shouldFilterException(err)) {
if (shouldCaptureException(err)) {
exceptionHandlerService?.captureExceptions([err]);
}

View File

@ -7,7 +7,7 @@ describe('Core REST API Authentication', () => {
path: `/people`,
bearer: '',
})
.expect(400)
.expect(403)
.expect((res) => {
expect(res.body.error).toBe('FORBIDDEN_EXCEPTION');
expect(res.body.messages[0]).toBe('Missing authentication token');