Skip to content

fix(server-utils): Derive the Vercel AI conversation id from the OpenAI conversation option - #24979

Open
RulaKhaled wants to merge 1 commit into
developfrom
fix/vercel-ai-conversation-id-from-openai-conversation
Open

RulaKhaled wants to merge 1 commit into
developfrom
fix/vercel-ai-conversation-id-from-openai-conversation

Conversation

@RulaKhaled

Copy link
Copy Markdown
Collaborator

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

…AI conversation option

`gen_ai.conversation.id` was filled with `providerMetadata.openai.responseId`,
the id of the response that just came back, so every call got its own value
under a grouping attribute. The id now comes from
`providerOptions.openai.conversation` (or `azure`), the Conversations API id
that is the same on every turn. Child spans inherit it from the active
operation span, and `Sentry.setConversationId()` still wins.

Part of #24832

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled requested a review from a team as a code owner October 2, 2026 10:51
@RulaKhaled
RulaKhaled requested review from andreiborza and isaacs and removed request for a team October 2, 2026 10:51
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 29.52 kB - -
@sentry/browser - with treeshaking flags 27.68 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.57 kB - -
@sentry/browser (incl. Tracing) 51.45 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 51.46 kB - -
@sentry/browser (incl. Tracing, Profiling) 54.46 kB - -
@sentry/browser (incl. Tracing, Replay) 91.04 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.03 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 95.74 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 108.71 kB - -
@sentry/browser (incl. Feedback) 47.04 kB - -
@sentry/browser (incl. sendFeedback) 34.57 kB - -
@sentry/browser (incl. FeedbackAsync) 39.68 kB - -
@sentry/browser (incl. Metrics) 30.54 kB - -
@sentry/browser (incl. Logs) 30.83 kB - -
@sentry/browser (incl. Metrics & Logs) 31.48 kB - -
@sentry/react 31.36 kB - -
@sentry/react (incl. Tracing) 53.81 kB - -
@sentry/vue 37.51 kB - -
@sentry/vue (incl. Tracing) 54.33 kB - -
@sentry/svelte 29.55 kB - -
@sentry/remix (Remix 3 client bundle) 55.77 kB - -
CDN Bundle 31.22 kB - -
CDN Bundle (incl. Tracing) 51.98 kB - -
CDN Bundle (incl. Logs, Metrics) 33.46 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.91 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 74.21 kB - -
CDN Bundle (incl. Tracing, Replay) 89.56 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 91.53 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 95.72 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 97.71 kB - -
CDN Bundle - uncompressed 92.14 kB - -
CDN Bundle (incl. Tracing) - uncompressed 154.49 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 98.71 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 160.45 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 228.28 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 274.22 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 280.16 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 287.93 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 293.86 kB - -
@sentry/nextjs (client) 56.32 kB - -
@sentry/sveltekit (client) 51.87 kB - -
@sentry/core/server 40.59 kB - -
@sentry/core/browser 13.63 kB - -
@sentry/node 144.78 kB +0.07% +96 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 83.2 kB - -
@sentry/node - without tracing 93.3 kB +0.08% +68 B 🔺
@sentry/node - without channel injection 123 kB +0.08% +87 B 🔺
@sentry/aws-serverless 101.57 kB +0.07% +61 B 🔺
@sentry/cloudflare (withSentry) - minified 208.61 kB - -
@sentry/cloudflare (withSentry) 517.73 kB - -

View base workflow run

@isaacs isaacs left a comment

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.

There are some choices here that we should probably call out and I'd maybe add a follow-up for the openai inconsistency, but this is good overall.

Deferring reading the id from runtimeContext / experimental_telemetry.metadata until #24721 lands is sensible. That should be ready to go once the convention is approved.

@@ -417,11 +437,16 @@ export function createSpanFromMessage(
recordToolDescriptions(callId, event.tools);
}

// Only an operation's start event carries `providerOptions`; its model-call and tool events start
// while the operation span is active, so they inherit the id from it.
const conversationId = getOpenAiConversationId(event.providerOptions) ?? getActiveSpanConversationId();

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.

getActiveSpanConversationId() runs for every event type, root operations included. The PR description and the code comment describe it as "child model-call and tool spans inherit it from the active operation span". But for a root generateText/streamText/embed/rerank with no conversation option, the active span is whatever the user's code has active. If that span has gen_ai.conversation.id, the new operation takes it.

Two cases I found where this happens:

  • A generateText called from inside a tool's execute (a sub-agent) takes the outer conversation id from the execute_tool span. This holds even when the inner call uses previousResponseId only, which the scenario says must produce no id.
  • A Vercel AI call made inside a LangGraph node takes the LangGraph thread_id (packages/server-utils/src/ai/langgraph/index.ts line 127). A call inside a Mastra or Flue span takes their conversation id in the same way.

This could be what we want! A sub-agent turn is arguably part of the same conversation. But the behavior is not stated, and it reaches past the "operation => child" scope that the description claims. It also means the result depends on which span is active at the call site, not on the call's own arguments.

Suggestion: Pick one approach and make it explicit. To match the description, I'd copy the id only for child events:

Suggested change
const conversationId = getOpenAiConversationId(event.providerOptions) ?? getActiveSpanConversationId();
const conversationId =
getOpenAiConversationId(event.providerOptions) ??
(ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId());

If cross-operation copying is wanted, we should say so in the comment and the PR description, and add a test scenario turn that runs generateText inside a tool execute and asserts the result, so that it's clearly intended.

},
});

// Chaining on the previous response names a response, not a thread, so no conversation id.

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.

Definitely out of scope for this PR, but I noticed that the direct OpenAI integration does the reverse of this comment: extractConversationId in packages/server-utils/src/ai/openai/utils.ts line 161 maps previous_response_id to gen_ai.conversation.id. That has the same problem you're fixing in this PR, because the value differs on each turn of a chain. So the same OpenAI conversation now gets different gen_ai.conversation.id values depending on whether the user calls OpenAI directly or through the AI SDK.

I'd suggest naming it in the description here as a todo, and adding a follow-up task to #24832 so we can converge on a single rule.

Comment on lines +155 to +156
const openaiOptions = providerOptions.openai ?? providerOptions.azure;
return isObjectLike(openaiOptions) ? asString(openaiOptions.conversation) : undefined;

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.

Hm, this will stop at the first object-like openai option, not the first that has a conversation ID. Maybe unlikely, but someone might have multiple options for different providers or something, like { openai: { reasoningEffort: 'low' }, azure: { conversation: 'conv_xyz' } }

Suggested change
const openaiOptions = providerOptions.openai ?? providerOptions.azure;
return isObjectLike(openaiOptions) ? asString(openaiOptions.conversation) : undefined;
for (const key of ['openai', 'azure']) {
const options = providerOptions[key];
const conversation = isObjectLike(options) ? asString(options.conversation) : undefined;
if (conversation) {
return conversation;
}
}
return undefined;

Comment on lines +784 to +786
// Cache/reasoning token breakdown is derived from the model's `providerMetadata` — by the
// OTel processor on v6 and by the channel subscriber on v7, both via the shared
// `getProviderMetadataAttributes` helper, so the shape is identical.

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.

OTel processor no longer exists, right?

Suggested change
// Cache/reasoning token breakdown is derived from the model's `providerMetadata` — by the
// OTel processor on v6 and by the channel subscriber on v7, both via the shared
// `getProviderMetadataAttributes` helper, so the shape is identical.
// Cache/reasoning token breakdown is derived from the model's `providerMetadata` by the
// channel subscriber, which v6 reaches through the orchestrion adapter, so the shape is the
// same on both versions.

This branch has not been deployed

No deployments
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