dfea3af778
## What & why Two related fixes that clean up Sentry reporting for the AI chat flow. ### 1. Enriched zero-output stream captures The AI chat stream's rejection handler previously skipped only `AbortError` and captured everything else to Sentry as-is. Two problems: - The SDK's bare `NoOutputGeneratedError` carries no troubleshooting context, so the Sentry issues were unactionable (no model, provider, workspace, or conversation size). - Expected interruptions (user abort, `STREAM_INTERRUPTED`) still generated noise. The rejection handler now handles three cases inline: - `AbortError` and `STREAM_INTERRUPTED` are expected interruptions and are not captured. - `NoOutputGeneratedError` is replaced with a single error whose message carries the full context as plain JSON: model, provider, workspace, thread, stream, turn, message count, conversation size, elapsed time, and the underlying stream error - recorded via a new `onError` handler, which also keeps stream-level errors visible in the worker logs. - Anything else is captured unchanged. The stable message prefix and single capture site keep zero-output events grouped separately from raw provider errors in Sentry. ### 2. Keep client-error domain exceptions out of Sentry `BILLING_CREDITS_EXHAUSTED` (a 402, i.e. an expected "user out of credits" condition) was landing in Sentry. Root cause: `CustomException` carries no HTTP status, so the worker/BullMQ path hands the raw exception to `shouldCaptureException`, which can't tell a 4xx client error from a 5xx server error and captures everything. The GraphQL/REST edges convert exceptions first, but background jobs bypass those converters. Fix, mirroring how `HttpException.getStatus()` already works: - `CustomException` gains an intrinsic `statusCode`. - `shouldCaptureException` skips a `CustomException` whose `statusCode < 500`, as a branch symmetric to the existing `HttpException` check. This covers every path, including the worker. - `BillingException` populates `statusCode` from the existing `getBillingExceptionStatusCode` mapping, so credits-exhausted (402) stays out of Sentry while the 500-mapped billing codes are still captured. Exceptions that don't set `statusCode` default to undefined and are captured exactly as before, so other domains are unaffected until they opt in. ## Tests - ai-chat unit suite passes (13 suites, 76 tests). - Existing billing exception handler tests pass. - `typecheck` passes. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23426?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
178 lines
4.5 KiB
TypeScript
178 lines
4.5 KiB
TypeScript
import { HttpException } from '@nestjs/common';
|
|
|
|
import { GraphQLError } from 'graphql';
|
|
|
|
import { type ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
|
|
import { type ExceptionHandlerWorkspace } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface';
|
|
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
|
|
|
import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
|
import {
|
|
AuthenticationError,
|
|
BaseGraphQLError,
|
|
ConflictError,
|
|
ErrorCode,
|
|
ForbiddenError,
|
|
MethodNotAllowedError,
|
|
NotFoundError,
|
|
TimeoutError,
|
|
ValidationError,
|
|
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
|
import { CustomException } from 'src/utils/custom-exception';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
const graphQLPredefinedExceptions = {
|
|
400: ValidationError,
|
|
401: AuthenticationError,
|
|
403: ForbiddenError,
|
|
404: NotFoundError,
|
|
405: MethodNotAllowedError,
|
|
408: TimeoutError,
|
|
409: ConflictError,
|
|
};
|
|
|
|
export const graphQLErrorCodesToFilter = [
|
|
ErrorCode.GRAPHQL_VALIDATION_FAILED,
|
|
ErrorCode.UNAUTHENTICATED,
|
|
ErrorCode.FORBIDDEN,
|
|
ErrorCode.NOT_FOUND,
|
|
ErrorCode.METHOD_NOT_ALLOWED,
|
|
ErrorCode.TIMEOUT,
|
|
ErrorCode.CONFLICT,
|
|
ErrorCode.BAD_USER_INPUT,
|
|
ErrorCode.METADATA_VALIDATION_FAILED,
|
|
];
|
|
|
|
export const handleExceptionAndConvertToGraphQLError = (
|
|
exception: Error,
|
|
exceptionHandlerService: ExceptionHandlerService,
|
|
user?: ExceptionHandlerUser,
|
|
workspace?: ExceptionHandlerWorkspace,
|
|
): BaseGraphQLError => {
|
|
handleException({
|
|
exception,
|
|
exceptionHandlerService,
|
|
user,
|
|
workspace,
|
|
});
|
|
|
|
return convertExceptionToGraphQLError(exception);
|
|
};
|
|
|
|
export const shouldCaptureException = (
|
|
exception: Error,
|
|
statusCode?: number,
|
|
): boolean => {
|
|
if (
|
|
exception instanceof CustomException &&
|
|
isDefined(exception.statusCode) &&
|
|
exception.statusCode < 500
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
exception instanceof GraphQLError &&
|
|
(exception?.extensions?.http?.status ?? 500) < 500
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
exception instanceof BaseGraphQLError &&
|
|
graphQLErrorCodesToFilter.includes(exception?.extensions?.code)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (exception instanceof HttpException && exception.getStatus() < 500) {
|
|
return false;
|
|
}
|
|
|
|
if (statusCode && statusCode < 500) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export const handleException = <
|
|
T extends Error | CustomException | HttpException,
|
|
>({
|
|
exception,
|
|
exceptionHandlerService,
|
|
user,
|
|
workspace,
|
|
statusCode,
|
|
shouldBeCapturedBySentry = true,
|
|
}: {
|
|
exception: T;
|
|
exceptionHandlerService: ExceptionHandlerService;
|
|
user?: ExceptionHandlerUser;
|
|
workspace?: ExceptionHandlerWorkspace;
|
|
statusCode?: number;
|
|
shouldBeCapturedBySentry?: boolean;
|
|
}): T => {
|
|
if (
|
|
shouldBeCapturedBySentry &&
|
|
shouldCaptureException(exception, statusCode)
|
|
) {
|
|
exceptionHandlerService.captureExceptions([exception], { user, workspace });
|
|
}
|
|
|
|
return exception;
|
|
};
|
|
|
|
export const convertExceptionToGraphQLError = (
|
|
exception: Error,
|
|
): BaseGraphQLError => {
|
|
if (exception instanceof HttpException) {
|
|
return convertHttpExceptionToGraphql(exception);
|
|
}
|
|
if (exception instanceof BaseGraphQLError) {
|
|
return exception;
|
|
}
|
|
|
|
return convertExceptionToGraphql(exception);
|
|
};
|
|
|
|
const convertHttpExceptionToGraphql = (exception: HttpException) => {
|
|
const status = exception.getStatus();
|
|
let error: BaseGraphQLError;
|
|
|
|
if (status in graphQLPredefinedExceptions) {
|
|
// @ts-expect-error legacy noImplicitAny
|
|
const message = exception.getResponse()['message'] ?? exception.message;
|
|
|
|
// @ts-expect-error legacy noImplicitAny
|
|
error = new graphQLPredefinedExceptions[exception.getStatus()](message);
|
|
} else {
|
|
error = new BaseGraphQLError(
|
|
'Internal Server Error',
|
|
exception.getStatus().toString(),
|
|
);
|
|
}
|
|
|
|
// Only show the stack trace in development mode
|
|
if (process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT) {
|
|
error.stack = exception.stack;
|
|
error.extensions['response'] = exception.getResponse();
|
|
}
|
|
|
|
return error;
|
|
};
|
|
|
|
export const convertExceptionToGraphql = (exception: Error) => {
|
|
const error = new BaseGraphQLError(
|
|
'Internal Server Error',
|
|
ErrorCode.INTERNAL_SERVER_ERROR,
|
|
);
|
|
|
|
if (process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT) {
|
|
error.stack = exception.stack;
|
|
error.extensions['response'] = exception.message;
|
|
}
|
|
|
|
return error;
|
|
};
|