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

@ -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;