Is there an existing issue for this?
Related: #17675 (fixed in 10.37.0 by #18973). That fix suppresses these rejections by name (AI_NoOutputGeneratedError, AbortError). This report covers the same path when the abort reason has a different name, for example a string or a plain Error. The underlying unhandled promise is still created.
How do you use Sentry?
Sentry SaaS (sentry.io)
Which SDK are you using?
@sentry/node (via @sentry/hono)
SDK Version
10.75.0 (latest). Also reproduced on 10.69.0 and on 11.0.0-rc.0 (see the v11 section below).
Framework Version
ai 6.0.191, Node 24.16 and 20.20. In production we run hono with @hono/node-server 2.0.4, but the repro below needs neither.
Reproduction Example/SDK Setup
Two files plus npm i @sentry/node@10.75.0 ai@6.0.191 ("type": "module"):
// instrument.mjs
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: 'https://public@o0.ingest.sentry.io/0', // any DSN; nothing is sent
tracesSampleRate: 1.0, // enables the default tracing integrations, incl. VercelAI
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend(event) {
const ex = event.exception?.values?.[0];
console.log(`[sentry] captured: ${ex?.type}: ${ex?.value} (mechanism: ${ex?.mechanism?.type})`);
return null;
},
});
// repro.mjs
import { streamText } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
// A model whose first response never arrives before the abort, like a real
// provider still waiting on response headers. It rejects with the signal's
// reason, which is what fetch() does when its signal aborts.
const model = new MockLanguageModelV3({
doStream: ({ abortSignal }) =>
new Promise((_, reject) => {
abortSignal.addEventListener('abort', () => reject(abortSignal.reason), { once: true });
}),
});
// Abort with a non-AbortError reason. @hono/node-server does exactly this when
// the client disconnects: abortController.abort('Client connection prematurely closed.')
const controller = new AbortController();
const reason = process.argv[2] === 'no-reason' ? undefined : 'Client connection prematurely closed.';
const result = streamText({ model, prompt: 'hi', abortSignal: controller.signal });
setTimeout(() => controller.abort(reason), 50);
// Consume the stream the way a route handler would. This is fully handled:
// textStream ends quietly on abort.
let text = '';
for await (const delta of result.textStream) text += delta;
console.log(`textStream finished (${text.length} chars)`);
await new Promise((r) => setTimeout(r, 200));
Steps to Reproduce
node --import ./instrument.mjs repro.mjs
Controls, all run on 10.75.0:
| run |
result |
node --import ./instrument.mjs repro.mjs (string reason) |
unhandled rejection, captured |
same, with abort(new Error('client went away')) |
unhandled rejection, captured |
node repro.mjs (no Sentry) |
clean |
node --import ./instrument.mjs repro.mjs no-reason (DOMException AbortError) |
clean (suppressed by the #18973 ignore list) |
same as the first run, with integrations: (d) => d.filter((i) => i.name !== 'VercelAI') |
clean |
Expected Result
Nothing is reported. The application consumes textStream, which ends normally on abort. No application promise is left unhandled.
Actual Result
textStream finished (0 chars)
This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason:
Client connection prematurely closed.
[sentry] captured: Error: Client connection prematurely closed. (mechanism: auto.node.onunhandledrejection)
In production this shows up as an unhandled, error-level event whose stack contains only Node and onunhandledrejection.js frames, with no application code. Every client that disconnects before the model's first byte produces one.
Additional Context
Cause (10.x): in @sentry/node 10.x, src/integrations/tracing/vercelai/instrumentation.ts, the streamText proxy's success callback calls processToolCallResults(result):
function processToolCallResults(result) {
if (typeof result !== 'object' || result === null || !('content' in result)) return;
const resultObj = result;
if (!Array.isArray(resultObj.content)) return; // <- reads the getter
...
}
On a DefaultStreamTextResult, content is a getter that returns a new promise each time it is read:
get steps() { this.consumeStream(); return this._steps.promise; }
get finalStep() { return this.steps.then((steps) => steps[steps.length - 1]); }
get content() { return this.finalStep.then((step) => step.content); }
The Array.isArray check reads the getter, gets a promise, returns false, and drops it. Nothing attaches a handler. If the stream is aborted before the first step completes, _steps rejects with the abort reason, and that derived promise becomes an unhandled rejection. You can confirm this without Sentry: adding void result.content; right after streamText(...) in the repro produces the identical unhandled rejection.
Users can't work around it from application code. Each read of content creates a separate promise, so result.content.catch(() => {}) doesn't handle the one Sentry created.
Suggested fix (both versions): whenever the instrumentation reads a field of an AI SDK result that may be a promise, attach a rejection handler to that promise. Better still, don't read promise-valued getters on stream results at all. For example, skip processToolCallResults when result.content isn't a plain array without triggering the getter (check the property descriptor), or handle it as a promise:
const content = resultObj.content;
if (content && typeof content.then === 'function') {
content.then(processContent, () => {}); // stream results: process when settled, never leak
return;
}
A side effect worth noting: reading content goes through the steps getter, which calls consumeStream(), so the instrumentation also changes stream consumption behaviour for every streamText call.
v11 (11.0.0-rc.0) is affected too, and more heavily. The same repro with @sentry/node@11.0.0-rc.0 and no other changes produces 7 unhandled rejections (7 captured events) per abort instead of 1. The controls behave the same way: none without Sentry, none with VercelAI filtered out, none without tracesSampleRate, and none for a no-reason AbortError. The Proxy is gone on develop, but the new subscriber (@sentry/server-utils, integrations/vercel-ai/vercel-ai-dc-subscriber) reads several fields of the result (usage, response, providerMetadata, text / toolCalls / content, finishReason). On a StreamTextResult, each of those is a getter returning a fresh promise, so each read is another unhandled rejection. A fix aimed only at v10 would therefore miss v11.
Extending the default ignore list by name wouldn't cover this. The abort reason is whatever the caller passes to AbortController.abort(). Hono passes a string; other servers pass Errors or custom errors.
Is there an existing issue for this?
Related: #17675 (fixed in 10.37.0 by #18973). That fix suppresses these rejections by name (
AI_NoOutputGeneratedError,AbortError). This report covers the same path when the abort reason has a different name, for example a string or a plainError. The underlying unhandled promise is still created.How do you use Sentry?
Sentry SaaS (sentry.io)
Which SDK are you using?
@sentry/node (via @sentry/hono)
SDK Version
10.75.0 (latest). Also reproduced on 10.69.0 and on 11.0.0-rc.0 (see the v11 section below).
Framework Version
ai6.0.191, Node 24.16 and 20.20. In production we runhonowith@hono/node-server2.0.4, but the repro below needs neither.Reproduction Example/SDK Setup
Two files plus
npm i @sentry/node@10.75.0 ai@6.0.191("type": "module"):Steps to Reproduce
node --import ./instrument.mjs repro.mjsControls, all run on 10.75.0:
node --import ./instrument.mjs repro.mjs(string reason)abort(new Error('client went away'))node repro.mjs(no Sentry)node --import ./instrument.mjs repro.mjs no-reason(DOMExceptionAbortError)integrations: (d) => d.filter((i) => i.name !== 'VercelAI')Expected Result
Nothing is reported. The application consumes
textStream, which ends normally on abort. No application promise is left unhandled.Actual Result
In production this shows up as an unhandled, error-level event whose stack contains only Node and
onunhandledrejection.jsframes, with no application code. Every client that disconnects before the model's first byte produces one.Additional Context
Cause (10.x): in
@sentry/node10.x,src/integrations/tracing/vercelai/instrumentation.ts, thestreamTextproxy's success callback callsprocessToolCallResults(result):On a
DefaultStreamTextResult,contentis a getter that returns a new promise each time it is read:The
Array.isArraycheck reads the getter, gets a promise, returns false, and drops it. Nothing attaches a handler. If the stream is aborted before the first step completes,_stepsrejects with the abort reason, and that derived promise becomes an unhandled rejection. You can confirm this without Sentry: addingvoid result.content;right afterstreamText(...)in the repro produces the identical unhandled rejection.Users can't work around it from application code. Each read of
contentcreates a separate promise, soresult.content.catch(() => {})doesn't handle the one Sentry created.Suggested fix (both versions): whenever the instrumentation reads a field of an AI SDK result that may be a promise, attach a rejection handler to that promise. Better still, don't read promise-valued getters on stream results at all. For example, skip
processToolCallResultswhenresult.contentisn't a plain array without triggering the getter (check the property descriptor), or handle it as a promise:A side effect worth noting: reading
contentgoes through thestepsgetter, which callsconsumeStream(), so the instrumentation also changes stream consumption behaviour for everystreamTextcall.v11 (11.0.0-rc.0) is affected too, and more heavily. The same repro with
@sentry/node@11.0.0-rc.0and no other changes produces 7 unhandled rejections (7 captured events) per abort instead of 1. The controls behave the same way: none without Sentry, none withVercelAIfiltered out, none withouttracesSampleRate, and none for a no-reasonAbortError. The Proxy is gone ondevelop, but the new subscriber (@sentry/server-utils,integrations/vercel-ai/vercel-ai-dc-subscriber) reads several fields of the result (usage,response,providerMetadata,text/toolCalls/content,finishReason). On aStreamTextResult, each of those is a getter returning a fresh promise, so each read is another unhandled rejection. A fix aimed only at v10 would therefore miss v11.Extending the default
ignorelist by name wouldn't cover this. The abort reason is whatever the caller passes toAbortController.abort(). Hono passes a string; other servers passErrors or custom errors.