Skip to content

Conversation

@RulaKhaled
Copy link
Member

@RulaKhaled RulaKhaled commented Nov 4, 2025

Fixes: https://linear.app/getsentry/issue/JS-1103/spans-are-not-flushed-to-dashboard-when-using-streamtext-with-vercel

The Cloudflare request wrapper was ending the root HTTP span immediately when the handler returned a streaming Response (e.g. result.toTextStreamResponse()). Since Vercel AI child spans only finish after the stream is consumed by the client, they were filtered out by Sentry's isFullFinishedSpan check, resulting in transactions with 0 spans.


This PR implements a streaming response detection and handles this from within the http handler:

  1. Created classifyResponseStreaming() helper

    • Detects streaming vs non streaming, via Content-Type (SSE), Content-Length
  2. Updated request wrapper

    • Changed from startSpan() to startSpanManual() for manual span control
    • Monitors streaming response consumption in background
    • Ends root span only after stream fully consumed by client

@linear
Copy link

linear bot commented Nov 4, 2025

@github-actions
Copy link
Contributor

github-actions bot commented Nov 4, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 24.8 kB - -
@sentry/browser - with treeshaking flags 23.31 kB - -
@sentry/browser (incl. Tracing) 41.54 kB - -
@sentry/browser (incl. Tracing, Profiling) 46.13 kB - -
@sentry/browser (incl. Tracing, Replay) 79.96 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 69.69 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 84.64 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 96.88 kB - -
@sentry/browser (incl. Feedback) 41.48 kB - -
@sentry/browser (incl. sendFeedback) 29.49 kB - -
@sentry/browser (incl. FeedbackAsync) 34.47 kB - -
@sentry/react 26.52 kB - -
@sentry/react (incl. Tracing) 43.74 kB - -
@sentry/vue 29.25 kB - -
@sentry/vue (incl. Tracing) 43.34 kB - -
@sentry/svelte 24.82 kB - -
CDN Bundle 27.21 kB - -
CDN Bundle (incl. Tracing) 42.21 kB - -
CDN Bundle (incl. Tracing, Replay) 78.75 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 84.2 kB - -
CDN Bundle - uncompressed 79.96 kB - -
CDN Bundle (incl. Tracing) - uncompressed 125.34 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 241.37 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 254.13 kB - -
@sentry/nextjs (client) 45.96 kB - -
@sentry/sveltekit (client) 41.9 kB - -
@sentry/node-core 51.19 kB - -
@sentry/node 159.36 kB - -
@sentry/node - without tracing 92.83 kB - -
@sentry/aws-serverless 108.08 kB - -

View base workflow run

@github-actions
Copy link
Contributor

github-actions bot commented Nov 17, 2025

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 10,618 - 8,853 +20%
GET With Sentry 1,792 17% 1,777 +1%
GET With Sentry (error only) 6,867 65% 6,031 +14%
POST Baseline 1,061 - 1,207 -12%
POST With Sentry 472 44% 597 -21%
POST With Sentry (error only) 907 85% 1,060 -14%
MYSQL Baseline 3,575 - 3,301 +8%
MYSQL With Sentry 420 12% 490 -14%
MYSQL With Sentry (error only) 3,034 85% 2,724 +11%

View base workflow run

@RulaKhaled RulaKhaled requested review from AbhiPrasad and Lms24 and removed request for Lms24 November 21, 2025 12:49
@RulaKhaled RulaKhaled changed the title WIP fix(cloudflare): Keep http root span alive until streaming responses are consumed fix(cloudflare): Keep http root span alive until streaming responses are consumed Nov 21, 2025
@RulaKhaled RulaKhaled marked this pull request as ready for review November 21, 2025 12:50
Comment on lines 133 to 143
const result = await reader.read();
done = result.done;
}
} catch {
// Stream error or cancellation - will end span in finally
} finally {
reader.releaseLock();
span.end();
waitUntil?.(flush(2000));
}
})();

This comment was marked as outdated.

Copy link
Member

Choose a reason for hiding this comment

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

This is a good point, let's try to be extra-careful here :)

}
})();

waitUntil?.(streamMonitor);
Copy link
Member

Choose a reason for hiding this comment

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

q: why do we need this call? (just asking, no objections)

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll add a comment here! We just have to wait for stream consumption and end span when complete

Comment on lines 133 to 143
const result = await reader.read();
done = result.done;
}
} catch {
// Stream error or cancellation - will end span in finally
} finally {
reader.releaseLock();
span.end();
waitUntil?.(flush(2000));
}
})();
Copy link
Member

Choose a reason for hiding this comment

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

This is a good point, let's try to be extra-careful here :)

}

// Classify response to detect actual streaming
const classification = classifyResponseStreaming(res);
Copy link
Member

Choose a reason for hiding this comment

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

m: what happens if we mis-classify a non-streaming response as a stream response? Would we break anything with creating the stream readers?

I guess the other way around isn't "dangerous", given we'd just end the span too early, correct?

Copy link
Member Author

Choose a reason for hiding this comment

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

so for false positives (non-streaming → classified as streaming) functionality wise it won't break anything, the only downside is slightly more overhead (creating an unnecessary stream monitor), and for false negatives yes you're correct, it would end span too early, but that's our best guess for now

* We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags)
* or SSR streams that may not have data ready immediately.
*/
export function classifyResponseStreaming(res: Response): StreamingGuess {
Copy link
Member

Choose a reason for hiding this comment

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

l/nit: Is there a reason why we return the response as well? I don't see any changes being made to it, so could a caller just directly reuse res instead of using the returned response?

Copy link
Member Author

Choose a reason for hiding this comment

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

nice catch! I was mutating the res in a previous commit, these are just leftovers 😅

@RulaKhaled RulaKhaled requested a review from Lms24 November 28, 2025 14:43
Copy link
Member

@Lms24 Lms24 left a comment

Choose a reason for hiding this comment

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

Thanks for making the changes! I think with this, we can at least ensure that the http.server stays alive. I'm a bit worried about the span not being active long enough though (because startSpanManual keeps the span only active as long as the callback is executed). But I think we can give this a try because at least the span length should now be correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants