Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Sentry from '@sentry/node';
import { streamText } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';

async function run() {
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
const controller = new AbortController();

// Abort the moment the model is asked for a stream, so the operation fails before its first
// chunk. `fetch()` rejects with the signal's reason on abort, and `@hono/node-server` aborts
// with a plain string — so there is no `AbortError` name to suppress by.
const model = new MockLanguageModelV3({
doStream: ({ abortSignal }) =>
new Promise((_, reject) => {
abortSignal.addEventListener('abort', () => reject(abortSignal.reason), { once: true });
controller.abort('Client connection prematurely closed.');
}),
});

const result = streamText({
experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true },
maxRetries: 0,
model,
prompt: 'Stream me a response',
abortSignal: controller.signal,
});

for await (const _part of result.textStream) {
void _part;
}
});
}

run();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test omits expected abort handling

Medium Severity

The aborted-stream scenario iterates textStream and leaves run uncaught, so the abort itself can reject. Sentry's default unhandled-rejection warn mode then writes to stderr, which makes ensureNoErrorOutput fail even after the instrumentation fix and prevents the test from isolating the extra rejection this PR addresses. The sibling scenario-rejected-model scenario catches the expected failure; this one does not.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit f9c08eb. Configure here.

Original file line number Diff line number Diff line change
Expand Up @@ -935,4 +935,20 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
},
},
);

createEsmTests(
__dirname,
'scenario-aborted-stream-text.mjs',
'instrument.mjs',
(createRunner, test) => {
test('aborting a stream with a non-AbortError reason leaves no unhandled rejection', async () => {
await createRunner().ensureNoErrorOutput().start().completed();
});
},
{
additionalDependencies: {
ai: vercelAiVersion,
},
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export function processToolCallResults(result: unknown): void {
return;
}

// A streamed result derives `content` from a stream that hasn't settled, so reading it both
// starts draining the stream and hands back a promise we drop — which surfaces as an unhandled
// rejection once the stream fails. Streamed tool errors were never processed here anyway.
if (typeof (result as { consumeStream?: unknown }).consumeStream === 'function') {
return;
Comment thread
Lms24 marked this conversation as resolved.
}

const resultObj = result as { content: Array<object> };
if (!Array.isArray(resultObj.content)) {
return;
Expand Down
8 changes: 7 additions & 1 deletion packages/server-utils/src/tracing-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type TracingChannelPayloadWithSpan<TData extends object> = TData & {
* The context's active store value, used to restore the context for asyncStart continuations for callback-based tracing.
*/
_sentryCallerStore?: unknown;

/** Set by Node's tracing channel when the traced operation failed. */
error?: unknown;
};

/*
Expand Down Expand Up @@ -63,7 +66,7 @@ export interface TracingChannelLifeCycleOptions<TData extends object = object> {
deferSpanEnd?: (args: {
span: Span;
data: TracingChannelPayloadWithSpan<TData>;
/** Ends the span: `end()` on success, `end(error)` on failure. Idempotent. */
/** Ends the span: `end()` on success, `end(error)` on failure (which marks `data` as errored). Idempotent. */
end: (error?: unknown) => void;
}) => boolean;

Expand Down Expand Up @@ -145,6 +148,9 @@ export function bindTracingChannelToSpan<TData extends object>(
ended = true;
if (error !== undefined) {
annotateSpanError(span, error);
// Without this the payload still looks successful, so `beforeSpanEnd` enriches the span from
// a `result` the operation never produced.
data.error = error;
}

endBoundSpan(data, beforeSpanEnd);
Expand Down
26 changes: 26 additions & 0 deletions packages/server-utils/test/tracing-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,32 @@ describe('bindTracingChannelToSpan', () => {
expect(endSpy).toHaveBeenCalledTimes(1);
});

it('`end(error)` marks the payload as failed for `beforeSpanEnd`', () => {
installTestAsyncContextStrategy();
initTestClient();
const span = startInactiveSpan({ name: 'channel-span' });
const beforeSpanEnd = vi.fn();
let captured: (error?: unknown) => void = () => undefined;
const { channel } = bindTracingChannelToSpan(
tracingChannel<{ operation: string }>('test:defer:payload-error'),
() => span,
{
beforeSpanEnd,
deferSpanEnd({ end }) {
captured = end;
return true;
},
},
);

channel.traceSync(() => 'stream', { operation: 'read' });
const error = new Error('stream aborted');
captured(error);

expect(beforeSpanEnd).toHaveBeenCalledTimes(1);
expect(beforeSpanEnd).toHaveBeenCalledWith(span, expect.objectContaining({ error }));
});

it('captures the error via `end(error)` when `captureError` is set', () => {
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
const { end } = setupDeferred('test:defer:capture', { captureError: true });
Expand Down
Loading