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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user