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.

<!-- 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. -->
This commit is contained in:
Etienne
2026-07-28 15:21:30 +02:00
committed by GitHub
parent ceb699c43f
commit dfea3af778
4 changed files with 61 additions and 2 deletions
@@ -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<BillingExceptionCode> {
userFriendlyMessage:
userFriendlyMessage ?? getBillingExceptionUserFriendlyMessage(code),
});
this.statusCode = getBillingExceptionStatusCode(this);
}
}
@@ -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<ToolSet>[]) => {
const usage = steps.reduce<LanguageModelUsage>(
@@ -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]);
});
@@ -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
@@ -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;