From dfea3af7785e4a1355cf4919a11a61b2db3824e8 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:30 +0200 Subject: [PATCH] fix(ai-chat): enrich zero-output stream captures and keep client-error exceptions out of Sentry (#23426) ## 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. Review in cubic --- .../core-modules/billing/billing.exception.ts | 2 + .../services/chat-execution.service.ts | 45 +++++++++++++++++++ .../utils/global-exception-handler.util.ts | 11 ++++- .../src/utils/custom-exception.ts | 5 ++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index ff8f3ace02..1fa3446f41 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -4,6 +4,7 @@ import { type MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { assertUnreachable } from 'twenty-shared/utils'; +import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util'; import { CustomException } from 'src/utils/custom-exception'; export enum BillingExceptionCode { @@ -107,5 +108,6 @@ export class BillingException extends CustomException { userFriendlyMessage: userFriendlyMessage ?? getBillingExceptionUserFriendlyMessage(code), }); + this.statusCode = getBillingExceptionStatusCode(this); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index d74109dff5..c91cfafae3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -4,6 +4,7 @@ import { convertToModelMessages, hasToolCall, type LanguageModelUsage, + NoOutputGeneratedError, stepCountIs, type StepResult, streamText, @@ -335,6 +336,7 @@ export class ChatExecutionService { let stepStartedAt = streamStartedAt; let ttftRecorded = false; let stepIndex = 0; + let lastUnderlyingStreamError: unknown; const emitTurnUsageEvent = async (steps: StepResult[]) => { const usage = steps.reduce( @@ -482,6 +484,12 @@ export class ChatExecutionService { }); } }, + onError: ({ error }) => { + lastUnderlyingStreamError = error; + this.logger.error( + `Stream ${streamId} emitted an error: ${error instanceof Error ? error.message : String(error)}`, + ); + }, experimental_onToolCallFinish: (event) => { this.metricsService.recordHistogram({ key: MetricsKeys.AiChatToolExecutionDurationMs, @@ -599,6 +607,43 @@ export class ChatExecutionService { if (error?.name === 'AbortError') { return; } + + if ( + error instanceof AiException && + error.code === AiExceptionCode.STREAM_INTERRUPTED + ) { + return; + } + + if (NoOutputGeneratedError.isInstance(error)) { + const underlying = lastUnderlyingStreamError; + + this.exceptionHandlerService.captureExceptions([ + Object.assign( + new Error( + `AI chat stream produced no output. ${JSON.stringify({ + modelId: registeredModel.modelId, + provider: registeredModel.sdkPackage, + workspaceId: workspace.id, + threadId, + streamId, + turnId, + messageCount: messages.length, + conversationSizeTokens, + elapsedMs: Math.round(performance.now() - streamStartedAt), + underlyingError: + underlying instanceof Error + ? `${underlying.name}: ${underlying.message}` + : String(underlying ?? 'none-recorded'), + })}`, + ), + { cause: underlying }, + ), + ]); + + return; + } + this.exceptionHandlerService.captureExceptions([error]); }); diff --git a/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts b/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts index 3e7507a3e9..1154d159f3 100644 --- a/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts @@ -18,7 +18,8 @@ import { TimeoutError, ValidationError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { type CustomException } from 'src/utils/custom-exception'; +import { CustomException } from 'src/utils/custom-exception'; +import { isDefined } from 'twenty-shared/utils'; const graphQLPredefinedExceptions = { 400: ValidationError, @@ -62,6 +63,14 @@ 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 diff --git a/packages/twenty-server/src/utils/custom-exception.ts b/packages/twenty-server/src/utils/custom-exception.ts index 321dd5b7f8..db473a57e1 100644 --- a/packages/twenty-server/src/utils/custom-exception.ts +++ b/packages/twenty-server/src/utils/custom-exception.ts @@ -22,11 +22,14 @@ export abstract class CustomException< > extends CustomError { code: ExceptionCode; userFriendlyMessage: MessageDescriptor; + statusCode?: number; constructor( message: ExceptionMessage, code: ExceptionCode, - { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + { + userFriendlyMessage, + }: { userFriendlyMessage: MessageDescriptor; statusCode?: number }, ) { super(message); this.code = code;