Skip to content

Commit 05fd48d

Browse files
committed
cleanup
1 parent 98fac43 commit 05fd48d

3 files changed

Lines changed: 137 additions & 37 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as Sentry from '@sentry/node';
2+
import { streamText } from 'ai';
3+
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
4+
5+
function makeStreamModel(text) {
6+
return new MockLanguageModelV3({
7+
doStream: async () => ({
8+
stream: simulateReadableStream({
9+
chunks: [
10+
{ type: 'stream-start', warnings: [] },
11+
{ type: 'text-start', id: '0' },
12+
{ type: 'text-delta', id: '0', delta: text },
13+
{ type: 'text-end', id: '0' },
14+
{
15+
type: 'finish',
16+
finishReason: { unified: 'stop', raw: 'stop' },
17+
usage: {
18+
inputTokens: { total: 10, noCache: 10, cached: 0 },
19+
outputTokens: { total: 20, noCache: 20, cached: 0 },
20+
totalTokens: { total: 30, noCache: 30, cached: 0 },
21+
},
22+
},
23+
],
24+
}),
25+
}),
26+
});
27+
}
28+
29+
async function consume(result) {
30+
for await (const _part of result.fullStream) {
31+
void _part;
32+
}
33+
}
34+
35+
async function run() {
36+
// A single model instance shared by two *concurrent* streamText calls. The shared model carries
37+
// only a single captured-parent slot, so naive parent tracking could attribute a model call to
38+
// whichever operation resolved the model last. Each `generate_content` (doStream) must land under
39+
// its own `invoke_agent`, and both invoke_agents under `main`.
40+
const sharedModel = makeStreamModel('shared!');
41+
42+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
43+
// Start both operations before consuming either, so both resolve the shared model first.
44+
const streams = [
45+
streamText({
46+
experimental_telemetry: { isEnabled: true },
47+
model: sharedModel,
48+
prompt: 'Concurrent stream A?',
49+
}),
50+
streamText({
51+
experimental_telemetry: { isEnabled: true },
52+
model: sharedModel,
53+
prompt: 'Concurrent stream B?',
54+
}),
55+
];
56+
57+
await Promise.all(streams.map(consume));
58+
});
59+
}
60+
61+
run();

dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,57 @@ describe.each([
526526
},
527527
);
528528

529+
createEsmTests(
530+
__dirname,
531+
'scenario-concurrent-stream.mjs',
532+
'instrument.mjs',
533+
(createRunner, test) => {
534+
// A single model instance shared by two concurrent `streamText` calls carries only one
535+
// captured-parent slot, so both model calls must still land under their own `invoke_agent` — not
536+
// collapse onto whichever operation resolved the shared model last.
537+
test.skipIf(version === '7' && nodeVersion === 18)(
538+
'parents concurrent streamText calls that share one model instance correctly',
539+
async () => {
540+
await createRunner()
541+
.withEnv(env)
542+
.expect({ transaction: { transaction: 'main' } })
543+
.expect({
544+
span: container => {
545+
const invokeAgents = container.items.filter(
546+
span => span.attributes?.['sentry.op']?.value === 'gen_ai.invoke_agent',
547+
);
548+
const generateContents = container.items.filter(
549+
span => span.attributes?.['sentry.op']?.value === 'gen_ai.generate_content',
550+
);
551+
552+
// Two concurrent operations -> two invoke_agent + two generate_content spans.
553+
expect(invokeAgents).toHaveLength(2);
554+
expect(generateContents).toHaveLength(2);
555+
556+
const agentSpanIds = new Set(invokeAgents.map(span => span.span_id));
557+
558+
// Each model call lands under an invoke_agent span...
559+
for (const span of generateContents) {
560+
expect(agentSpanIds.has(span.parent_span_id!)).toBe(true);
561+
}
562+
// ...a distinct one each (no cross-attribution despite the shared model instance)...
563+
expect(new Set(generateContents.map(span => span.parent_span_id)).size).toBe(2);
564+
// ...and both operations sit under the same `main` parent.
565+
expect(new Set(invokeAgents.map(span => span.parent_span_id)).size).toBe(1);
566+
},
567+
})
568+
.start()
569+
.completed();
570+
},
571+
);
572+
},
573+
{
574+
additionalDependencies: {
575+
ai: vercelAiVersion,
576+
},
577+
},
578+
);
579+
529580
createEsmTests(
530581
__dirname,
531582
'scenario-stream-text.mjs',

packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-v6-subscriber.ts

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
22
import 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';
44
import { DEBUG_BUILD } from '../debug-build';
55
import { CHANNELS } from '../orchestrion/channels';
66
import { bindTracingChannelToSpan, type TracingChannelPayloadWithSpan } from '../tracing-channel';
@@ -58,10 +58,9 @@ interface ResolvedModel {
5858
}
5959

6060
const 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.
6766
let 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.
8587
const operationParentStore = new AsyncLocalStorage<Span | undefined>();
8688

8789
let 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

293281
function 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

Comments
 (0)