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.
---
.../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;