Skip to content

Commit 2cc2b29

Browse files
RulaKhaledclaude
andauthored
fix(server-utils): Derive the Vercel AI conversation id from the OpenAI conversation option (#24979)
The Vercel AI integration filled `gen_ai.conversation.id` with `providerMetadata.openai.responseId`. That is the id of the response that just came back, so every call got a different value under a grouping attribute and showed up as its own one-span conversation. The same value is already on `gen_ai.response.id`. The mapping came in with #16992 and #19903 later had to guard it from overwriting user-set ids. The id now comes from `providerOptions.openai.conversation` (or `azure`), the Conversations API `conv_` id, which is the same on every turn. Child model-call and tool spans inherit it from the active operation span. `Sentry.setConversationId()` still wins, since `conversationIdIntegration` writes the scope value on `spanStart` after these start attributes. The v6 adapter forwards `providerOptions` so `ai` 4 to 6 behave the same. Not in this PR: reading the id from `runtimeContext` / `experimental_telemetry.metadata` goes with #24706 once #24721 lands. Part of #24832 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0369ca9 commit 2cc2b29

7 files changed

Lines changed: 342 additions & 21 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ describe('Vercel AI integration (v4)', () => {
492492
});
493493

494494
createEsmAndCjsTests(__dirname, 'scenario-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => {
495-
test('does not overwrite conversation id set via Sentry.setConversationId with responseId from provider metadata', async () => {
495+
test('keeps the conversation id set via Sentry.setConversationId and ignores the provider responseId', async () => {
496496
await createRunner()
497497
.expect({ transaction: { transaction: 'main' } })
498498
.expect({
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as Sentry from '@sentry/node';
2+
import { generateText, tool } from 'ai';
3+
import { MockLanguageModelV3 } from 'ai/test';
4+
import { z } from 'zod';
5+
6+
const usage = {
7+
inputTokens: { total: 10, noCache: 10, cached: 0 },
8+
outputTokens: { total: 5, noCache: 5, cached: 0 },
9+
totalTokens: { total: 15, noCache: 15, cached: 0 },
10+
};
11+
12+
const textModel = new MockLanguageModelV3({
13+
doGenerate: async () => ({
14+
finishReason: { unified: 'stop', raw: 'stop' },
15+
usage,
16+
content: [{ type: 'text', text: 'Hello!' }],
17+
warnings: [],
18+
// A per-response id: present on every turn, never the conversation id.
19+
providerMetadata: { openai: { responseId: 'resp_turn' } },
20+
}),
21+
});
22+
23+
const toolCallModel = new MockLanguageModelV3({
24+
doGenerate: async () => ({
25+
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
26+
usage,
27+
content: [{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'echo', input: JSON.stringify({ text: 'hi' }) }],
28+
warnings: [],
29+
}),
30+
});
31+
32+
async function run() {
33+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
34+
// A turn of an OpenAI Conversations API conversation.
35+
await generateText({
36+
experimental_telemetry: { isEnabled: true },
37+
model: textModel,
38+
prompt: 'First turn',
39+
providerOptions: { openai: { conversation: 'conv_abc123' } },
40+
});
41+
42+
// The Azure Responses API carries the same option under the `azure` key; this turn also runs a tool.
43+
await generateText({
44+
experimental_telemetry: { isEnabled: true },
45+
model: toolCallModel,
46+
prompt: 'Second turn',
47+
providerOptions: { azure: { conversation: 'conv_azure' } },
48+
tools: {
49+
echo: tool({
50+
inputSchema: z.object({ text: z.string() }),
51+
execute: async ({ text }) => text,
52+
}),
53+
},
54+
});
55+
56+
// Chaining on the previous response names a response, not a thread, so no conversation id.
57+
await generateText({
58+
experimental_telemetry: { isEnabled: true },
59+
model: textModel,
60+
prompt: 'Chained turn',
61+
providerOptions: { openai: { previousResponseId: 'resp_turn' } },
62+
});
63+
64+
// An id set through the SDK API wins over the provider option. Last, since it stays on the scope.
65+
Sentry.setConversationId('conv-from-api');
66+
await generateText({
67+
experimental_telemetry: { isEnabled: true },
68+
model: textModel,
69+
prompt: 'API turn',
70+
providerOptions: { openai: { conversation: 'conv_ignored' } },
71+
});
72+
});
73+
}
74+
75+
run();

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

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
767767
'scenario-provider-metadata.mjs',
768768
'instrument.mjs',
769769
(createRunner, test) => {
770-
test('derives provider-metadata token breakdown, conversation id and system instructions', async () => {
770+
test('derives provider-metadata token breakdown and system instructions', async () => {
771771
await createRunner()
772772
.expect({ transaction: { transaction: 'main' } })
773773
.expect({
@@ -781,12 +781,13 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
781781
)!;
782782
expect(generateContent).toBeDefined();
783783

784-
// Cache/reasoning token breakdown and conversation id are derived from the model's
785-
// `providerMetadata` — by the OTel processor on v6 and by the channel subscriber on v7,
786-
// both via the shared `getProviderMetadataAttributes` helper, so the shape is identical.
784+
// Cache/reasoning token breakdown is derived from the model's `providerMetadata` by the
785+
// channel subscriber, which v6 reaches through the orchestrion adapter, so the shape is the
786+
// same on both versions.
787787
expect(generateContent.attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]?.value).toBe(5);
788788
expect(generateContent.attributes[GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]?.value).toBe(7);
789-
expect(generateContent.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe('resp_abc123');
789+
// The per-response `responseId` is not a conversation id and must not be recorded as one.
790+
expect(generateContent.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();
790791

791792
const invokeAgent = container.items.find(
792793
span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
@@ -813,6 +814,66 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
813814
},
814815
);
815816

817+
createEsmTests(
818+
__dirname,
819+
'scenario-openai-conversation.mjs',
820+
'instrument.mjs',
821+
(createRunner, test) => {
822+
test('derives gen_ai.conversation.id from the OpenAI `conversation` provider option', async () => {
823+
await createRunner()
824+
.expect({ transaction: { transaction: 'main' } })
825+
.expect({
826+
span: container => {
827+
const genAiSpans = container.items.filter(s =>
828+
String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'),
829+
);
830+
const conversationIdOf = (span: (typeof genAiSpans)[number]) =>
831+
span.attributes[GEN_AI_CONVERSATION_ID]?.value;
832+
const invokeAgentSpans = genAiSpans.filter(
833+
s => s.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
834+
);
835+
expect(invokeAgentSpans).toHaveLength(4);
836+
const [firstTurn, secondTurn, chainedTurn, apiTurn] = invokeAgentSpans.sort(
837+
(a, b) => a.start_timestamp - b.start_timestamp,
838+
);
839+
840+
// `providerOptions.openai.conversation` is the Conversations API id: the same on every turn.
841+
expect(conversationIdOf(firstTurn!)).toBe('conv_abc123');
842+
// The Azure Responses API uses the `azure` key for the same option.
843+
expect(conversationIdOf(secondTurn!)).toBe('conv_azure');
844+
// `previousResponseId` names a response rather than a thread, and the response's own
845+
// `responseId` is recorded as `gen_ai.response.id` only.
846+
expect(conversationIdOf(chainedTurn!)).toBeUndefined();
847+
// `Sentry.setConversationId()` beats the provider option.
848+
expect(conversationIdOf(apiTurn!)).toBe('conv-from-api');
849+
850+
// Model-call and tool spans carry their operation's id, even though their start events
851+
// do not carry `providerOptions`.
852+
const modelCallSpans = genAiSpans.filter(
853+
s => s.attributes['sentry.op']?.value === 'gen_ai.generate_content',
854+
);
855+
expect(modelCallSpans.map(conversationIdOf).sort()).toEqual([
856+
'conv-from-api',
857+
'conv_abc123',
858+
'conv_azure',
859+
undefined,
860+
]);
861+
const toolSpan = genAiSpans.find(s => s.attributes['sentry.op']?.value === 'gen_ai.execute_tool')!;
862+
expect(toolSpan).toBeDefined();
863+
expect(conversationIdOf(toolSpan)).toBe('conv_azure');
864+
},
865+
})
866+
.start()
867+
.completed();
868+
});
869+
},
870+
{
871+
additionalDependencies: {
872+
ai: vercelAiVersion,
873+
},
874+
},
875+
);
876+
816877
createEsmTests(
817878
__dirname,
818879
'scenario-cache-tokens.mjs',

‎packages/server-utils/src/ai/vercel-ai/index.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { SpanAttributeValue } from '@sentry/core';
22
import {
3-
GEN_AI_CONVERSATION_ID,
43
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
54
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
65
GEN_AI_USAGE_OUTPUT_TOKENS,
@@ -10,8 +9,8 @@ import {
109
import type { OpenAiProviderMetadata, ProviderMetadata } from './vercel-ai-attributes';
1110

1211
/**
13-
* Derive the `gen_ai.usage.*` cache/reasoning/prediction token attributes and `gen_ai.conversation.id`
14-
* from an AI SDK `providerMetadata` object.
12+
* Derive the `gen_ai.usage.*` cache/reasoning/prediction token attributes from an AI SDK
13+
* `providerMetadata` object.
1514
*
1615
* Used by the `ai` >= 7 tracing-channel subscriber, which receives `providerMetadata` as an object on
1716
* the channel result. Pass the already-parsed object; unknown/empty input yields `{}`.
@@ -39,7 +38,6 @@ export function getProviderMetadataAttributes(providerMetadata: unknown): Record
3938
'gen_ai.usage.output_tokens.prediction_rejected',
4039
openaiMetadata.rejectedPredictionTokens,
4140
);
42-
setAttributeIfDefined(attributes, GEN_AI_CONVERSATION_ID, openaiMetadata.responseId);
4341
}
4442

4543
if (metadata.anthropic) {

‎packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { Span, SpanAttributes } from '@sentry/core';
3939
import {
4040
_INTERNAL_skipAiProviderWrapping,
4141
captureException,
42+
getActiveSpan,
4243
getClient,
4344
isObjectLike,
4445
SPAN_STATUS_ERROR,
@@ -141,6 +142,31 @@ export function clearOperationCallId(callId: string): void {
141142
invokeAgentSpanByCallId.delete(callId);
142143
}
143144

145+
/**
146+
* The OpenAI Conversations API id from `providerOptions.openai.conversation` (or `azure`): the one
147+
* provider-level value that is the same on every turn. A `Sentry.setConversationId()` value still wins,
148+
* since `conversationIdIntegration` writes it on `spanStart`, after these start attributes.
149+
*/
150+
function getOpenAiConversationId(providerOptions: unknown): string | undefined {
151+
if (!isObjectLike(providerOptions)) {
152+
return undefined;
153+
}
154+
for (const key of ['openai', 'azure']) {
155+
const options = providerOptions[key];
156+
const conversation = isObjectLike(options) ? asString(options.conversation) : undefined;
157+
if (conversation) {
158+
return conversation;
159+
}
160+
}
161+
return undefined;
162+
}
163+
164+
/** The `gen_ai.conversation.id` already on the active span, which for a child event is its operation span. */
165+
function getActiveSpanConversationId(): string | undefined {
166+
const active = getActiveSpan();
167+
return active ? asString(spanToJSON(active).attributes[GEN_AI_CONVERSATION_ID]) : undefined;
168+
}
169+
144170
/**
145171
* `providerMetadata` is last-step only; drop derived usage on spans that report an aggregate.
146172
*
@@ -417,12 +443,20 @@ export function createSpanFromMessage(
417443
recordToolDescriptions(callId, event.tools);
418444
}
419445

446+
// Only an operation's start event carries `providerOptions`; its model-call and tool events start
447+
// while the operation span is active, so they inherit the id from it. A root operation never
448+
// inherits, so a nested call (e.g. inside a tool's `execute`) is not folded into the outer conversation.
449+
const conversationId =
450+
getOpenAiConversationId(event.providerOptions) ??
451+
(ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId());
452+
420453
const baseAttributes: SpanAttributes = {
421454
[SENTRY_ORIGIN]: ORIGIN,
422455
...telemetryMetadataAttributes(event.telemetryMetadata),
423456
...(provider ? { [GEN_AI_PROVIDER_NAME]: provider, [VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider } : {}),
424457
...(modelId ? { [GEN_AI_REQUEST_MODEL]: modelId } : {}),
425458
...(maxRetries !== undefined ? { [VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE]: maxRetries } : {}),
459+
...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
426460
};
427461

428462
switch (type) {
@@ -438,7 +472,7 @@ export function createSpanFromMessage(
438472

439473
return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId);
440474
case 'executeTool':
441-
return buildToolSpan(event, recordInputs);
475+
return buildToolSpan(event, recordInputs, conversationId);
442476
case 'embed':
443477
case 'embedMany': {
444478
// `embed` carries a single `value`; `embedMany` a `values` array — both map to the embeddings input.
@@ -542,7 +576,11 @@ function buildModelCallSpan(
542576
});
543577
}
544578

545-
function buildToolSpan(event: Record<string, unknown>, recordInputs: boolean): Span {
579+
function buildToolSpan(
580+
event: Record<string, unknown>,
581+
recordInputs: boolean,
582+
conversationId: string | undefined,
583+
): Span {
546584
const toolCall = isObjectLike(event.toolCall) ? event.toolCall : {};
547585
const toolName = asString(toolCall.toolName);
548586
const toolCallId = asString(event.toolCallId) ?? asString(toolCall.toolCallId);
@@ -557,6 +595,7 @@ function buildToolSpan(event: Record<string, unknown>, recordInputs: boolean): S
557595
...(toolCallId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId } : {}),
558596
...(description ? { [GEN_AI_TOOL_DESCRIPTION]: description } : {}),
559597
...(recordInputs && toolInput !== undefined ? { [GEN_AI_TOOL_CALL_ARGUMENTS]: stringify(toolInput) } : {}),
598+
...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
560599
});
561600
}
562601

@@ -628,17 +667,11 @@ export function enrichSpanOnEnd(
628667
span.setAttribute(GEN_AI_RESPONSE_MODEL, responseModel);
629668
}
630669

631-
// Provider-specific cache/reasoning/prediction token breakdowns and `gen_ai.conversation.id`.
632-
// The channel exposes `providerMetadata` as an object (the OTel path parses it from a string);
633-
// both share `getProviderMetadataAttributes` so the emitted shape is identical.
670+
// Provider-specific cache/reasoning/prediction token breakdowns. The channel exposes `providerMetadata`
671+
// as an object (the OTel path parses it from a string); both share `getProviderMetadataAttributes` so
672+
// the emitted shape is identical.
634673
const providerMetadata = (result as { providerMetadata?: unknown }).providerMetadata;
635674
const providerAttributes = getProviderMetadataAttributes(providerMetadata);
636-
// Don't overwrite a conversation id already set on span start (e.g. by `conversationIdIntegration`
637-
// from a user-set scope value); the provider-derived id is only a fallback. Matches the OTel path.
638-
if (GEN_AI_CONVERSATION_ID in providerAttributes && spanToJSON(span).attributes[GEN_AI_CONVERSATION_ID]) {
639-
// oxlint-disable-next-line typescript/no-dynamic-delete
640-
delete providerAttributes[GEN_AI_CONVERSATION_ID];
641-
}
642675
dropLastStepOnlyUsage(providerAttributes, type);
643676
span.setAttributes(providerAttributes);
644677

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,9 @@ function buildTextMessage(type: 'generateText' | 'streamText' | 'generateObject'
676676
// Normalize to the message-array shape the shared core (and v7's channel) expects: a bare string
677677
// `prompt` becomes a single user message, matching the SDK's own normalization.
678678
messages: normalizePromptMessages(options),
679+
// v7's native start event carries `providerOptions`; the shared core reads the OpenAI
680+
// Conversations API id from it.
681+
providerOptions: options.providerOptions,
679682
telemetryMetadata: telemetry.metadata,
680683
...recording(telemetry),
681684
},

0 commit comments

Comments
 (0)