Skip to content

feat(server-utils): Capture usage and output on streamed Vercel AI v7 spans#22000

Draft
logaretm wants to merge 5 commits into
developfrom
awad/vercelai-channel-stream-support
Draft

feat(server-utils): Capture usage and output on streamed Vercel AI v7 spans#22000
logaretm wants to merge 5 commits into
developfrom
awad/vercelai-channel-stream-support

Conversation

@logaretm

@logaretm logaretm commented Jul 6, 2026

Copy link
Copy Markdown
Member

Streamed streamText calls weren't getting usage tokens, finish reason, or output text on their spans. The ai:telemetry channel resolves the model call as soon as it hands back the stream, so that data only appears later as the stream drains and never reached us.

This taps the streamed model call's ReadableStream, forwards every chunk to the SDK untouched, and aggregates the final usage/finish/output so the span can be enriched and closed once the stream settles. Usage is summed across the operation's model calls and, along with the output, propagated onto the parent invoke_agent span, whose own channel result is always undefined for a stream.

This is v7 (native channel) only. Structured output rides the same path since streamObject is deprecated in v7 in favor of streamText({ experimental_output }).

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.59 kB - -
@sentry/browser - with treeshaking flags 26.03 kB - -
@sentry/browser (incl. Tracing) 46.27 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 48.03 kB - -
@sentry/browser (incl. Tracing, Profiling) 51.03 kB - -
@sentry/browser (incl. Tracing, Replay) 85.56 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 75.17 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 90.26 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 102.91 kB - -
@sentry/browser (incl. Feedback) 44.76 kB - -
@sentry/browser (incl. sendFeedback) 32.38 kB - -
@sentry/browser (incl. FeedbackAsync) 37.51 kB - -
@sentry/browser (incl. Metrics) 28.67 kB - -
@sentry/browser (incl. Logs) 28.91 kB - -
@sentry/browser (incl. Metrics & Logs) 29.59 kB - -
@sentry/react 29.38 kB - -
@sentry/react (incl. Tracing) 48.53 kB - -
@sentry/vue 33.03 kB - -
@sentry/vue (incl. Tracing) 48.16 kB - -
@sentry/svelte 27.61 kB - -
CDN Bundle 30 kB - -
CDN Bundle (incl. Tracing) 48.26 kB - -
CDN Bundle (incl. Logs, Metrics) 31.57 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 49.57 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 70.81 kB - -
CDN Bundle (incl. Tracing, Replay) 85.73 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 87.04 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 91.55 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.83 kB - -
CDN Bundle - uncompressed 89.35 kB - -
CDN Bundle (incl. Tracing) - uncompressed 145.99 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 94.05 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 149.96 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.75 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 265.16 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 269.12 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 278.86 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 282.81 kB - -
@sentry/nextjs (client) 50.94 kB - -
@sentry/sveltekit (client) 46.68 kB - -
@sentry/core/server 78.39 kB - -
@sentry/core/browser 64.74 kB - -
@sentry/node-core 63.21 kB - -
@sentry/node 125.47 kB +0.59% +732 B 🔺
@sentry/node (incl. diagnostics channel injection) 131.92 kB +0.73% +952 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 69.95 kB - -
@sentry/node/light 50.72 kB - -
@sentry/node - without tracing 74.82 kB - -
@sentry/aws-serverless 85.6 kB - -
@sentry/cloudflare (withSentry) - minified 181.68 kB - -
@sentry/cloudflare (withSentry) 449.39 kB - -

View base workflow run

// channel already resolved the model call. The native-channel (v7) tap and the v6 OTel
// spans both carry them; the v6 orchestrion adapter rebuilds spans from v6's synchronous
// result shapes, which never include the drained stream, so scope these to the rest.
if (!(version === '6' && isOrchestrionEnabled())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we fix this for v6 orchestrion too? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I wasn't sure about the orchistrion payload so never tried, lmk see if I can

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in f643e7c and fc1b724

// See the Node-18 note on `scenario-stream-text.mjs` above. The v6 orchestrion adapter rebuilds
// spans from v6's synchronous result shapes (no drained stream), so this stream-enrichment test
// runs against the native-channel (v7) and OTel (v6) paths only.
test.skipIf((version === '7' && nodeVersion === 18) || (version === '6' && isOrchestrionEnabled()))(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

see #21984, when merged this can/should be removed!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cool, I will wait for it to be merged then!

// See the Node-18 note on `scenario-stream-text.mjs` above. The v6 orchestrion adapter rebuilds
// spans from v6's synchronous result shapes (no drained stream), so this stream-enrichment test
// runs against the native-channel (v7) and OTel (v6) paths only.
test.skipIf((version === '7' && nodeVersion === 18) || (version === '6' && isOrchestrionEnabled()))(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

cc #21984

logaretm added 5 commits July 7, 2026 17:15
… spans

The `ai:telemetry` channel resolves a `streamText` model call as soon as it
hands back the unconsumed stream, so usage, finish reason and output text
(which only arrive as the stream drains) never reached the gen_ai spans.

Tap the streamed model call's ReadableStream: forward every chunk to the SDK
untouched, aggregate the final usage/finish/output, then enrich and end the
model-call span out of band via the tracing-channel `deferSpanEnd` hook. Sum
the usage across the operation's model calls and propagate it, plus the output
messages, onto the parent invoke_agent span, whose own channel result is always
undefined for a stream.

This is v7 (native channel) only. Structured-output streaming rides the same
path since `streamObject` is deprecated in v7 in favour of
`streamText({ experimental_output })`.
…-utils

Move the state-free type-coercion helpers (asString, asNumber, sum, isRecord,
safeStringify) into a shared module and drop the duplicated copies in the
orchestrion subscriber and the inline checks in the stream tap.
…til.ts

Fold the streamed model-call tap and the pure helpers into a single
`vercel-ai/util.ts`. Core's `isPlainObject`/`isString` aren't drop-ins (the
former rejects tag-bearing objects like `ReadableStream`, the latter is a guard
not a coercion) and core has no single-value safe stringify, so the loose local
helpers stay.
…apter

Reuse the stream tap in the orchestrion `doStream` patch so v6 (orchestrion/CJS)
streamed model-call spans get usage, finish reason and output, matching v7 and
the v6 OTel path. The parent invoke_agent span can't be enriched here: v6
`streamText` ends its operation span synchronously, before the stream drains, so
that aggregate stays unset in orchestrion (tests gate it on `enrichesInvokeAgentForStream`).
Defer the orchestrion streamText operation span (via the deferSpanEnd hook the
binding already supports) and await the StreamTextResult's completion promises
(totalUsage/text/finishReason/toolCalls, resolved on drain) to enrich the
invoke_agent span. This closes the earlier gap where that span ended
synchronously before the stream drained, so v6 orchestrion now matches v7 and
the v6 OTel path for streamed usage and output.
@logaretm logaretm force-pushed the awad/vercelai-channel-stream-support branch from fc1b724 to 8dae3f0 Compare July 7, 2026 21:18
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.

2 participants