From de5fc51d62be90c287534a0ce9f7b92c9569d148 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 13:30:06 +0200 Subject: [PATCH 1/5] fix(server-utils): Surface deferred span-end errors on the channel payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An aborted `streamText` left up to 7 unhandled rejections per abort. The deferred end's `end(error)` annotated the span but never marked the payload, so `beforeSpanEnd` took the success branch and enriched the span from the raw `StreamTextResult` — whose `usage`/`response`/`providerMetadata`/`text`/… are getters that derive a *fresh* promise from the already-rejected stream on every read, each one leaking an unhandled rejection. `AbortError` reasons are suppressed by name in `onUnhandledRejectionIntegration`, but an abort reason is whatever the caller passes (`@hono/node-server` passes a string), so the ignore list can't cover this. Fixes #24532 Co-Authored-By: Claude Opus 5 (1M context) --- .../vercelai/v6_v7/instrument-abort.mjs | 9 +++++ .../v6_v7/scenario-aborted-stream-text.mjs | 39 +++++++++++++++++++ .../suites/tracing/vercelai/v6_v7/test.ts | 22 +++++++++++ packages/server-utils/src/tracing-channel.ts | 7 +++- .../server-utils/test/tracing-channel.test.ts | 29 ++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-abort.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-abort.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-abort.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-abort.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs new file mode 100644 index 000000000000..5444719ae017 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs @@ -0,0 +1,39 @@ +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 () => { + // 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 }); + }), + }); + + const controller = new AbortController(); + const result = streamText({ + experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true }, + maxRetries: 0, + model, + prompt: 'Stream me a response', + abortSignal: controller.signal, + }); + + // `@hono/node-server` aborts with a plain string when the client disconnects, so the reason is + // not an `AbortError` and can't be suppressed by name. + setTimeout(() => controller.abort('Client connection prematurely closed.'), 50); + + // Consuming `textStream` is fully handled: it ends quietly on abort. + for await (const _part of result.textStream) { + void _part; + } + }); + + await new Promise(resolve => setTimeout(resolve, 300)); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index d014e9308862..9be40e6a84a4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -955,4 +955,26 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe }, }, ); + + createEsmTests( + __dirname, + 'scenario-aborted-stream-text.mjs', + 'instrument-abort.mjs', + (createRunner, test) => { + // The instrumentation reads fields off the `StreamTextResult` to enrich the span. Each of + // those is a getter returning a *fresh* derived promise, so reading one without attaching a + // rejection handler leaks an unhandled rejection once the stream aborts. `AbortError` reasons + // are suppressed by name in `onUnhandledRejectionIntegration`, but an abort reason can be any + // value (`@hono/node-server` passes a string), so the leak has to be fixed at the source. + // `ensureNoErrorOutput` fails on any stderr, including the warn-mode rejection warning. + test('aborting a stream with a non-AbortError reason leaves no unhandled rejection', async () => { + await createRunner().ensureNoErrorOutput().start().completed(); + }); + }, + { + additionalDependencies: { + ai: vercelAiVersion, + }, + }, + ); }); diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index fb3d602ce1eb..ab6bd34d70d4 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -66,7 +66,7 @@ export interface TracingChannelLifeCycleOptions { deferSpanEnd?: (args: { span: Span; data: TracingChannelPayloadWithSpan; - /** 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; @@ -158,6 +158,11 @@ export function bindTracingChannelToSpan( ended = true; if (error !== undefined) { annotateSpanError(span, error); + // Mark the payload as failed so `beforeSpanEnd` sees the same shape the channel's own `error` + // verb produces. Otherwise a deferred failure still looks successful, and handlers enrich the + // span from a `result` the operation never produced — which on results whose fields are + // promise-valued getters (`ai`'s `StreamTextResult`) leaks one unhandled rejection per read. + (data as { error?: unknown }).error = error; } endBoundSpan(data, beforeSpanEnd); diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 9e1f15484134..18d25338cb6a 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -837,6 +837,35 @@ describe('bindTracingChannelToSpan', () => { expect(endSpy).toHaveBeenCalledTimes(1); }); + // A deferred failure has to reach `beforeSpanEnd` as an error, not as the `result` the operation + // never produced. Enrichment handlers branch on this, and reading fields off an unsettled + // streaming result leaks unhandled rejections (#24532). + 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 }); From 5675bab0cef7ef88eb8c193af4e232c67c1884f4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 14:16:30 +0200 Subject: [PATCH 2/5] test(vercelai): Pin what an aborted streamText reports The abort path had no coverage at all, so nothing recorded that enrichment used to backfill `gen_ai.response.model` from the request's model id on a span whose model never responded. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/vercelai/v6_v7/test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index 9be40e6a84a4..a9dd83efd63c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -970,6 +970,35 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe test('aborting a stream with a non-AbortError reason leaves no unhandled rejection', async () => { await createRunner().ensureNoErrorOutput().start().completed(); }); + + // Pins what the abort path reports. Both spans finish with an error status, but neither carries + // any attribute derived from the result — the operation produced none. `gen_ai.response.model` + // in particular used to be backfilled from the request's model id even though the model never + // responded; enrichment is skipped entirely now that the payload is marked as failed. + test('an aborted stream finishes its spans with an error status and no result attributes', async () => { + await createRunner() + .expect({ + span: container => { + const invokeAgent = container.items.find( + span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent', + )!; + expect(invokeAgent).toBeDefined(); + expect(invokeAgent.status).toBe('error'); + expect(invokeAgent.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mock-model-id'); + expect(invokeAgent.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(invokeAgent.attributes[GEN_AI_USAGE_TOTAL_TOKENS]).toBeUndefined(); + expect(invokeAgent.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + + const generateContent = container.items.find( + span => span.attributes['sentry.op']?.value === 'gen_ai.generate_content', + )!; + expect(generateContent).toBeDefined(); + expect(generateContent.status).toBe('error'); + }, + }) + .start() + .completed(); + }); }, { additionalDependencies: { From 3318d2780e167a22f89a334593928555171a6cda Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 14:49:48 +0200 Subject: [PATCH 3/5] ref: Trim comments Co-Authored-By: Claude Opus 5 (1M context) --- .../vercelai/v6_v7/scenario-aborted-stream-text.mjs | 9 +++------ .../suites/tracing/vercelai/v6_v7/test.ts | 10 ---------- packages/server-utils/src/tracing-channel.ts | 6 ++---- packages/server-utils/test/tracing-channel.test.ts | 3 --- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs index 5444719ae017..68cddc746c9e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs @@ -4,9 +4,8 @@ import { MockLanguageModelV3 } from 'ai/test'; async function run() { await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // 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. + // `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) => { @@ -23,16 +22,14 @@ async function run() { abortSignal: controller.signal, }); - // `@hono/node-server` aborts with a plain string when the client disconnects, so the reason is - // not an `AbortError` and can't be suppressed by name. setTimeout(() => controller.abort('Client connection prematurely closed.'), 50); - // Consuming `textStream` is fully handled: it ends quietly on abort. for await (const _part of result.textStream) { void _part; } }); + // Unhandled rejections are reported a turn later; don't let the process exit before that. await new Promise(resolve => setTimeout(resolve, 300)); } diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index a9dd83efd63c..2468f974af55 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -961,20 +961,10 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe 'scenario-aborted-stream-text.mjs', 'instrument-abort.mjs', (createRunner, test) => { - // The instrumentation reads fields off the `StreamTextResult` to enrich the span. Each of - // those is a getter returning a *fresh* derived promise, so reading one without attaching a - // rejection handler leaks an unhandled rejection once the stream aborts. `AbortError` reasons - // are suppressed by name in `onUnhandledRejectionIntegration`, but an abort reason can be any - // value (`@hono/node-server` passes a string), so the leak has to be fixed at the source. - // `ensureNoErrorOutput` fails on any stderr, including the warn-mode rejection warning. test('aborting a stream with a non-AbortError reason leaves no unhandled rejection', async () => { await createRunner().ensureNoErrorOutput().start().completed(); }); - // Pins what the abort path reports. Both spans finish with an error status, but neither carries - // any attribute derived from the result — the operation produced none. `gen_ai.response.model` - // in particular used to be backfilled from the request's model id even though the model never - // responded; enrichment is skipped entirely now that the payload is marked as failed. test('an aborted stream finishes its spans with an error status and no result attributes', async () => { await createRunner() .expect({ diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index ab6bd34d70d4..6e7d90ac7d42 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -158,10 +158,8 @@ export function bindTracingChannelToSpan( ended = true; if (error !== undefined) { annotateSpanError(span, error); - // Mark the payload as failed so `beforeSpanEnd` sees the same shape the channel's own `error` - // verb produces. Otherwise a deferred failure still looks successful, and handlers enrich the - // span from a `result` the operation never produced — which on results whose fields are - // promise-valued getters (`ai`'s `StreamTextResult`) leaks one unhandled rejection per read. + // Without this the payload still looks successful, so `beforeSpanEnd` enriches the span from + // a `result` the operation never produced. (data as { error?: unknown }).error = error; } diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 18d25338cb6a..693be7de75df 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -837,9 +837,6 @@ describe('bindTracingChannelToSpan', () => { expect(endSpy).toHaveBeenCalledTimes(1); }); - // A deferred failure has to reach `beforeSpanEnd` as an error, not as the `result` the operation - // never produced. Enrichment handlers branch on this, and reading fields off an unsettled - // streaming result leaks unhandled rejections (#24532). it('`end(error)` marks the payload as failed for `beforeSpanEnd`', () => { installTestAsyncContextStrategy(); initTestClient(); From b6eca78531c7fd249b9276e7bddef5814662b3dc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 15:03:10 +0200 Subject: [PATCH 4/5] ref(server-utils): Declare `error` on the channel payload type Removes the cast in the deferred end: the field is part of the payload's real shape (Node writes it, and every channel context declares it), it was just missing from `TracingChannelPayloadWithSpan`. Co-Authored-By: Claude Opus 5 (1M context) --- packages/server-utils/src/tracing-channel.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 6e7d90ac7d42..075057f47c31 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -26,6 +26,9 @@ export type TracingChannelPayloadWithSpan = 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; }; /* @@ -160,7 +163,7 @@ export function bindTracingChannelToSpan( annotateSpanError(span, error); // Without this the payload still looks successful, so `beforeSpanEnd` enriches the span from // a `result` the operation never produced. - (data as { error?: unknown }).error = error; + data.error = error; } endBoundSpan(data, beforeSpanEnd); From 5a4993e34ee83e6c8a42e481f7860f4d67257c72 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 15:26:14 +0200 Subject: [PATCH 5/5] test(vercelai): Drop both timers from the abort scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aborting from inside `doStream` hits the same pre-first-chunk race without a timer, and the trailing sleep was not load-bearing — the scenario still fails pre-fix without it. Co-Authored-By: Claude Opus 5 (1M context) --- .../v6_v7/scenario-aborted-stream-text.mjs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs index 68cddc746c9e..d7f4e7755f32 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-aborted-stream-text.mjs @@ -4,16 +4,19 @@ import { MockLanguageModelV3 } from 'ai/test'; async function run() { await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - // `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 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 controller = new AbortController(); const result = streamText({ experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true }, maxRetries: 0, @@ -22,15 +25,10 @@ async function run() { abortSignal: controller.signal, }); - setTimeout(() => controller.abort('Client connection prematurely closed.'), 50); - for await (const _part of result.textStream) { void _part; } }); - - // Unhandled rejections are reported a turn later; don't let the process exit before that. - await new Promise(resolve => setTimeout(resolve, 300)); } run();