Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ describe('Vercel AI integration (v4)', () => {
});

createEsmAndCjsTests(__dirname, 'scenario-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => {
test('does not overwrite conversation id set via Sentry.setConversationId with responseId from provider metadata', async () => {
test('keeps the conversation id set via Sentry.setConversationId and ignores the provider responseId', async () => {
await createRunner()
.expect({ transaction: { transaction: 'main' } })
.expect({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as Sentry from '@sentry/node';
import { generateText, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';

const usage = {
inputTokens: { total: 10, noCache: 10, cached: 0 },
outputTokens: { total: 5, noCache: 5, cached: 0 },
totalTokens: { total: 15, noCache: 15, cached: 0 },
};

const textModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: { unified: 'stop', raw: 'stop' },
usage,
content: [{ type: 'text', text: 'Hello!' }],
warnings: [],
// A per-response id: present on every turn, never the conversation id.
providerMetadata: { openai: { responseId: 'resp_turn' } },
}),
});

const toolCallModel = new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage,
content: [{ type: 'tool-call', toolCallId: 'tc-1', toolName: 'echo', input: JSON.stringify({ text: 'hi' }) }],
warnings: [],
}),
});

async function run() {
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
// A turn of an OpenAI Conversations API conversation.
await generateText({
experimental_telemetry: { isEnabled: true },
model: textModel,
prompt: 'First turn',
providerOptions: { openai: { conversation: 'conv_abc123' } },
});

// The Azure Responses API carries the same option under the `azure` key; this turn also runs a tool.
await generateText({
experimental_telemetry: { isEnabled: true },
model: toolCallModel,
prompt: 'Second turn',
providerOptions: { azure: { conversation: 'conv_azure' } },
tools: {
echo: tool({
inputSchema: z.object({ text: z.string() }),
execute: async ({ text }) => text,
}),
},
});

// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

yah noticed this, adding it to the description as a todo and will put a task on #24830

await generateText({
experimental_telemetry: { isEnabled: true },
model: textModel,
prompt: 'Chained turn',
providerOptions: { openai: { previousResponseId: 'resp_turn' } },
});

// An id set through the SDK API wins over the provider option. Last, since it stays on the scope.
Sentry.setConversationId('conv-from-api');
await generateText({
experimental_telemetry: { isEnabled: true },
model: textModel,
prompt: 'API turn',
providerOptions: { openai: { conversation: 'conv_ignored' } },
});
});
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
'scenario-provider-metadata.mjs',
'instrument.mjs',
(createRunner, test) => {
test('derives provider-metadata token breakdown, conversation id and system instructions', async () => {
test('derives provider-metadata token breakdown and system instructions', async () => {
await createRunner()
.expect({ transaction: { transaction: 'main' } })
.expect({
Expand All @@ -781,12 +781,13 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe
)!;
expect(generateContent).toBeDefined();

// Cache/reasoning token breakdown and conversation id are 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.
expect(generateContent.attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]?.value).toBe(5);
expect(generateContent.attributes[GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]?.value).toBe(7);
expect(generateContent.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe('resp_abc123');
// The per-response `responseId` is not a conversation id and must not be recorded as one.
expect(generateContent.attributes[GEN_AI_CONVERSATION_ID]).toBeUndefined();

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

createEsmTests(
__dirname,
'scenario-openai-conversation.mjs',
'instrument.mjs',
(createRunner, test) => {
test('derives gen_ai.conversation.id from the OpenAI `conversation` provider option', async () => {
await createRunner()
.expect({ transaction: { transaction: 'main' } })
.expect({
span: container => {
const genAiSpans = container.items.filter(s =>
String(s.attributes['sentry.op']?.value ?? '').startsWith('gen_ai.'),
);
const conversationIdOf = (span: (typeof genAiSpans)[number]) =>
span.attributes[GEN_AI_CONVERSATION_ID]?.value;
const invokeAgentSpans = genAiSpans.filter(
s => s.attributes['sentry.op']?.value === 'gen_ai.invoke_agent',
);
expect(invokeAgentSpans).toHaveLength(4);
const [firstTurn, secondTurn, chainedTurn, apiTurn] = invokeAgentSpans.sort(
(a, b) => a.start_timestamp - b.start_timestamp,
);

// `providerOptions.openai.conversation` is the Conversations API id: the same on every turn.
expect(conversationIdOf(firstTurn!)).toBe('conv_abc123');
// The Azure Responses API uses the `azure` key for the same option.
expect(conversationIdOf(secondTurn!)).toBe('conv_azure');
// `previousResponseId` names a response rather than a thread, and the response's own
// `responseId` is recorded as `gen_ai.response.id` only.
expect(conversationIdOf(chainedTurn!)).toBeUndefined();
// `Sentry.setConversationId()` beats the provider option.
expect(conversationIdOf(apiTurn!)).toBe('conv-from-api');

// Model-call and tool spans carry their operation's id, even though their start events
// do not carry `providerOptions`.
const modelCallSpans = genAiSpans.filter(
s => s.attributes['sentry.op']?.value === 'gen_ai.generate_content',
);
expect(modelCallSpans.map(conversationIdOf).sort()).toEqual([
'conv-from-api',
'conv_abc123',
'conv_azure',
undefined,
]);
const toolSpan = genAiSpans.find(s => s.attributes['sentry.op']?.value === 'gen_ai.execute_tool')!;
expect(toolSpan).toBeDefined();
expect(conversationIdOf(toolSpan)).toBe('conv_azure');
},
})
.start()
.completed();
});
},
{
additionalDependencies: {
ai: vercelAiVersion,
},
},
);

createEsmTests(
__dirname,
'scenario-cache-tokens.mjs',
Expand Down
6 changes: 2 additions & 4 deletions packages/server-utils/src/ai/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { SpanAttributeValue } from '@sentry/core';
import {
GEN_AI_CONVERSATION_ID,
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
Expand All @@ -10,8 +9,8 @@ import {
import type { OpenAiProviderMetadata, ProviderMetadata } from './vercel-ai-attributes';

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

if (metadata.anthropic) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type { Span, SpanAttributes } from '@sentry/core';
import {
_INTERNAL_skipAiProviderWrapping,
captureException,
getActiveSpan,
getClient,
isObjectLike,
SPAN_STATUS_ERROR,
Expand Down Expand Up @@ -141,6 +142,31 @@ export function clearOperationCallId(callId: string): void {
invokeAgentSpanByCallId.delete(callId);
}

/**
* The OpenAI Conversations API id from `providerOptions.openai.conversation` (or `azure`): the one
* provider-level value that is the same on every turn. A `Sentry.setConversationId()` value still wins,
* since `conversationIdIntegration` writes it on `spanStart`, after these start attributes.
*/
function getOpenAiConversationId(providerOptions: unknown): string | undefined {
if (!isObjectLike(providerOptions)) {
return 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;
}

/** The `gen_ai.conversation.id` already on the active span, which for a child event is its operation span. */
function getActiveSpanConversationId(): string | undefined {
const active = getActiveSpan();
return active ? asString(spanToJSON(active).attributes[GEN_AI_CONVERSATION_ID]) : undefined;
}

/**
* `providerMetadata` is last-step only; drop derived usage on spans that report an aggregate.
*
Expand Down Expand Up @@ -417,12 +443,20 @@ 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. A root operation never
// inherits, so a nested call (e.g. inside a tool's `execute`) is not folded into the outer conversation.
const conversationId =
getOpenAiConversationId(event.providerOptions) ??
(ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId());

const baseAttributes: SpanAttributes = {
[SENTRY_ORIGIN]: ORIGIN,
...telemetryMetadataAttributes(event.telemetryMetadata),
...(provider ? { [GEN_AI_PROVIDER_NAME]: provider, [VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider } : {}),
...(modelId ? { [GEN_AI_REQUEST_MODEL]: modelId } : {}),
...(maxRetries !== undefined ? { [VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE]: maxRetries } : {}),
...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
};

switch (type) {
Expand All @@ -438,7 +472,7 @@ export function createSpanFromMessage(

return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId);
case 'executeTool':
return buildToolSpan(event, recordInputs);
return buildToolSpan(event, recordInputs, conversationId);
case 'embed':
case 'embedMany': {
// `embed` carries a single `value`; `embedMany` a `values` array — both map to the embeddings input.
Expand Down Expand Up @@ -542,7 +576,11 @@ function buildModelCallSpan(
});
}

function buildToolSpan(event: Record<string, unknown>, recordInputs: boolean): Span {
function buildToolSpan(
event: Record<string, unknown>,
recordInputs: boolean,
conversationId: string | undefined,
): Span {
const toolCall = isObjectLike(event.toolCall) ? event.toolCall : {};
const toolName = asString(toolCall.toolName);
const toolCallId = asString(event.toolCallId) ?? asString(toolCall.toolCallId);
Expand All @@ -557,6 +595,7 @@ function buildToolSpan(event: Record<string, unknown>, recordInputs: boolean): S
...(toolCallId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId } : {}),
...(description ? { [GEN_AI_TOOL_DESCRIPTION]: description } : {}),
...(recordInputs && toolInput !== undefined ? { [GEN_AI_TOOL_CALL_ARGUMENTS]: stringify(toolInput) } : {}),
...(conversationId ? { [GEN_AI_CONVERSATION_ID]: conversationId } : {}),
});
}

Expand Down Expand Up @@ -628,17 +667,11 @@ export function enrichSpanOnEnd(
span.setAttribute(GEN_AI_RESPONSE_MODEL, responseModel);
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,9 @@ function buildTextMessage(type: 'generateText' | 'streamText' | 'generateObject'
// Normalize to the message-array shape the shared core (and v7's channel) expects: a bare string
// `prompt` becomes a single user message, matching the SDK's own normalization.
messages: normalizePromptMessages(options),
// v7's native start event carries `providerOptions`; the shared core reads the OpenAI
// Conversations API id from it.
providerOptions: options.providerOptions,
telemetryMetadata: telemetry.metadata,
...recording(telemetry),
},
Expand Down
Loading
Loading