11import { AsyncLocalStorage } from 'node:async_hooks' ;
22import type { Span } from '@sentry/core' ;
3- import { debug , getActiveSpan , SPAN_STATUS_ERROR , withActiveSpan } from '@sentry/core' ;
3+ import { debug , SPAN_STATUS_ERROR , withActiveSpan } from '@sentry/core' ;
44import { DEBUG_BUILD } from '../debug-build' ;
55import { CHANNELS } from '../orchestrion/channels' ;
66import { bindTracingChannelToSpan , type TracingChannelPayloadWithSpan } from '../tracing-channel' ;
@@ -58,10 +58,9 @@ interface ResolvedModel {
5858}
5959
6060const PATCHED = Symbol ( 'SentryVercelAiModelPatched' ) ;
61- const PARENT = Symbol ( 'SentryVercelAiModelParent' ) ;
6261
63- /** A resolved model with our patch bookkeeping (idempotency flag + captured parent span ). */
64- type PatchableModel = ResolvedModel & { [ PATCHED ] ?: boolean ; [ PARENT ] ?: Span } ;
62+ /** A resolved model with our patch bookkeeping (idempotency flag). */
63+ type PatchableModel = ResolvedModel & { [ PATCHED ] ?: boolean } ;
6564
6665// Per-operation correlation id. No Date/random (unavailable / non-deterministic) — a counter is enough.
6766let callIdCounter = 0 ;
@@ -80,8 +79,11 @@ const callIdBySpan = new WeakMap<Span, string>();
8079// Carries the enclosing operation span down to the patched `doGenerate`/`doStream`, where the active
8180// span is the `ai` SDK's own (ignored) model-call span rather than our operation span. It's bound onto
8281// the operation channel via `bindStore` (see `bindOperation`), so it's scoped per traced operation and
83- // propagates across the awaits inside it — which is what lets concurrent operations sharing a single
84- // model instance each resolve their own parent (the `[PARENT]` slot on the shared model cannot).
82+ // propagates across the awaits inside it. This holds for `streamText` too: `ai` initiates the model
83+ // stream synchronously inside `streamText` (within this bound context), so the later `doStream` — even
84+ // though it runs after the operation's span has already ended — still restores this store and reads ITS
85+ // operation's span. That per-operation scoping is the sole parent-resolution mechanism, and it is what
86+ // keeps concurrent operations sharing a single model instance from cross-attributing their model calls.
8587const operationParentStore = new AsyncLocalStorage < Span | undefined > ( ) ;
8688
8789let subscribed = false ;
@@ -206,7 +208,7 @@ function bindOperation(
206208 message . result = message . type === 'executeTool' ? { output : data . result } : data . result ;
207209 enrichSpanOnEnd ( span , message , options ) ;
208210 }
209- // A `streamText` model call runs lazily, after this (synchronously-returning) operation's span has
211+ // A `streamText` model call runs after this (synchronously-returning) operation's span has
210212 // already ended, so its `callId` entry must outlive the operation — it's cleared once the model
211213 // call settles (see `patchModelMethod`). Every other operation can clear here.
212214 if ( message . type !== 'streamText' ) {
@@ -235,15 +237,9 @@ function subscribeResolveLanguageModel(
235237 return ;
236238 }
237239 const model = ctx . result as PatchableModel ;
238- // `resolveLanguageModel` runs synchronously inside the operation body, where the operation span is
239- // the active span. `generateText`/`embed` recover that parent from `operationParentStore` at model
240- // call time, but `streamText` runs its model call lazily — after the operation's async context (and
241- // thus `operationParentStore`) has unwound — so stash the operation span on the model as its
242- // fallback parent here.
243- const active = getActiveSpan ( ) ;
244- if ( active && operationSpans . has ( active ) ) {
245- model [ PARENT ] = active ;
246- }
240+ // Patch the model's `doGenerate`/`doStream` once. The model call recovers its parent from
241+ // `operationParentStore` at call time (set per-operation by `bindOperation`), which propagates into
242+ // the model call for `streamText` too, so there is nothing to capture on the model here.
247243 if ( ! model [ PATCHED ] ) {
248244 model [ PATCHED ] = true ;
249245 patchModelMethod ( model , 'doGenerate' , options ) ;
@@ -266,28 +262,20 @@ function subscribeResolveLanguageModel(
266262}
267263
268264/**
269- * Pick the invoke_agent span a model call should hang under.
265+ * Pick the invoke_agent span a model call should hang under: the operation span bound onto
266+ * `operationParentStore` for the enclosing operation.
270267 *
271- * Prefer the active span when it is an operation span: for `generateText`/`embed` the model call is
272- * awaited inside the operation body, so the async context still carries the right operation span — and
273- * crucially this disambiguates concurrent calls that share one model instance (where the captured
274- * `model[PARENT]` would be whichever operation resolved the model last). Fall back to the captured
275- * parent for `streamText`, whose model call runs after the operation returned and the bound context has
276- * unwound . Returns `undefined` when neither is an operation span — e.g. telemetry was disabled for the
277- * enclosing call — so the model call is skipped too.
268+ * This covers `generateText`/`embed` (whose model call is awaited inside the operation body) and
269+ * `streamText` alike — `ai` initiates the stream synchronously within the operation's bound context, so
270+ * `doStream` restores the same per-operation store even though it runs after the operation's span has
271+ * ended. Being per-operation, the store disambiguates concurrent calls that share one model instance (a
272+ * single mutable slot on the shared model could not — it would hold whichever operation resolved the
273+ * model last) . Returns `undefined` when the store doesn't carry an operation span — e.g. telemetry was
274+ * disabled for the enclosing call — so the model call is skipped too.
278275 */
279- function resolveModelCallParent ( model : PatchableModel ) : Span | undefined {
280- // The model call is awaited inside the operation body (`generateText`/`embed`), so the operation span
281- // bound onto `operationParentStore` for that operation is still in scope — and is per-operation, so it
282- // stays correct even when concurrent operations share one model instance.
276+ function resolveModelCallParent ( ) : Span | undefined {
283277 const fromContext = operationParentStore . getStore ( ) ;
284- if ( fromContext && operationSpans . has ( fromContext ) ) {
285- return fromContext ;
286- }
287- // Fallback for `streamText`, whose `doStream` runs after the operation's async context has unwound:
288- // the parent captured on the model at resolve time.
289- const captured = model [ PARENT ] ;
290- return captured && operationSpans . has ( captured ) ? captured : undefined ;
278+ return fromContext && operationSpans . has ( fromContext ) ? fromContext : undefined ;
291279}
292280
293281function patchModelMethod (
@@ -300,7 +288,7 @@ function patchModelMethod(
300288 return ;
301289 }
302290 model [ method ] = function ( this : unknown , ...args : unknown [ ] ) : Promise < unknown > {
303- const parent = resolveModelCallParent ( model ) ;
291+ const parent = resolveModelCallParent ( ) ;
304292 // No enclosing operation span (e.g. telemetry disabled for the call) → don't open a model-call span.
305293 if ( ! parent ) {
306294 return Promise . resolve ( original . apply ( this , args ) ) ;
@@ -327,7 +315,7 @@ function patchModelMethod(
327315 }
328316
329317 // `streamText` ends its operation span synchronously, so its `callId` entry was deliberately left in
330- // place for this (lazy) model call; drop it now that we've used it.
318+ // place for this later model call; drop it now that we've used it.
331319 const clearStreamCallId = ( ) : void => {
332320 if ( method === 'doStream' && callId ) {
333321 clearOperationCallId ( callId ) ;
0 commit comments