Skip to content

Commit 67e7d68

Browse files
committed
fix(core): Set op on ended Vercel AI spans
1 parent 63daea2 commit 67e7d68

5 files changed

Lines changed: 197 additions & 73 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as Sentry from '@sentry/node';
2+
import { generateText } from 'ai';
3+
4+
// Custom mock model that doesn't set modelId initially (simulates late model ID setting)
5+
// This tests that processEndedVercelAiSpan correctly sets the op even when
6+
// processGenerateSpan didn't run due to missing model ID at span start
7+
class LateModelIdMock {
8+
specificationVersion = 'v1';
9+
provider = 'late-model-provider';
10+
// modelId is intentionally undefined initially to simulate late setting
11+
modelId = undefined;
12+
defaultObjectGenerationMode = 'json';
13+
14+
async doGenerate() {
15+
// Model ID is only "available" during generation, not at span start
16+
this.modelId = 'late-mock-model-id';
17+
18+
return {
19+
rawCall: { rawPrompt: null, rawSettings: {} },
20+
finishReason: 'stop',
21+
usage: { promptTokens: 5, completionTokens: 10 },
22+
text: 'Response from late model!',
23+
};
24+
}
25+
}
26+
27+
async function run() {
28+
await Sentry.startSpan({ op: 'function', name: 'main' }, async () => {
29+
await generateText({
30+
model: new LateModelIdMock(),
31+
prompt: 'Test prompt for late model ID',
32+
});
33+
});
34+
}
35+
36+
run();

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,4 +699,41 @@ describe('Vercel AI integration', () => {
699699
expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id);
700700
});
701701
});
702+
703+
createEsmAndCjsTests(__dirname, 'scenario-late-model-id.mjs', 'instrument.mjs', (createRunner, test) => {
704+
test('sets op correctly even when model ID is not available at span start', async () => {
705+
const expectedTransaction = {
706+
transaction: 'main',
707+
spans: expect.arrayContaining([
708+
// The generateText span should have the correct op even though model ID was not available at span start
709+
// Since processGenerateSpan didn't run, the span description remains 'ai.generateText'
710+
expect.objectContaining({
711+
description: 'ai.generateText',
712+
op: 'gen_ai.invoke_agent',
713+
origin: 'auto.vercelai.otel',
714+
status: 'ok',
715+
data: expect.objectContaining({
716+
'sentry.op': 'gen_ai.invoke_agent',
717+
'sentry.origin': 'auto.vercelai.otel',
718+
'gen_ai.operation.name': 'ai.generateText',
719+
}),
720+
}),
721+
// The doGenerate span should also have the correct op
722+
expect.objectContaining({
723+
description: 'ai.generateText.doGenerate',
724+
op: 'gen_ai.generate_text',
725+
origin: 'auto.vercelai.otel',
726+
status: 'ok',
727+
data: expect.objectContaining({
728+
'sentry.op': 'gen_ai.generate_text',
729+
'sentry.origin': 'auto.vercelai.otel',
730+
'gen_ai.operation.name': 'ai.generateText.doGenerate',
731+
}),
732+
}),
733+
]),
734+
};
735+
736+
await createRunner().expect({ transaction: expectedTransaction }).start().completed();
737+
});
738+
});
702739
});

packages/core/src/tracing/ai/gen-ai-attributes.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_to
179179
*/
180180
export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent';
181181

182+
/**
183+
* The span operation name for generating text
184+
*/
185+
export const GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_text';
186+
187+
/**
188+
* The span operation name for streaming text
189+
*/
190+
export const GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_text';
191+
192+
/**
193+
* The span operation name for generating object
194+
*/
195+
export const GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_object';
196+
197+
/**
198+
* The span operation name for streaming object
199+
*/
200+
export const GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_object';
201+
202+
/**
203+
* The span operation name for embedding
204+
*/
205+
export const GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed';
206+
207+
/**
208+
* The span operation name for embedding many
209+
*/
210+
export const GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed_many';
211+
212+
/**
213+
* The span operation name for executing a tool
214+
*/
215+
export const GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE = 'gen_ai.execute_tool';
216+
182217
// =============================================================================
183218
// OPENAI-SPECIFIC ATTRIBUTES
184219
// =============================================================================

packages/core/src/tracing/vercel-ai/index.ts

Lines changed: 47 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
accumulateTokensForParent,
2020
applyAccumulatedTokens,
2121
convertAvailableToolsToJsonString,
22+
getSpanOpFromName,
2223
requestMessagesFromPrompt,
2324
} from './utils';
2425
import type { ProviderMetadata } from './vercel-ai-attributes';
@@ -64,8 +65,17 @@ function onVercelAiSpanStart(span: Span): void {
6465
return;
6566
}
6667

67-
// The AI model ID must be defined for generate, stream, and embed spans.
68-
// The provider is optional and may not always be present.
68+
// Check if this is a Vercel AI span by name pattern.
69+
// We set origin even if model ID is missing, so processEndedVercelAiSpan
70+
// can still process the span when attributes are set late.
71+
if (!name.startsWith('ai.')) {
72+
return;
73+
}
74+
75+
addOriginToSpan(span, 'auto.vercelai.otel');
76+
77+
// The AI model ID must be defined for full generate span processing.
78+
// If it's not available at span start, processEndedVercelAiSpan will set the op.
6979
const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE];
7080
if (typeof aiModelId !== 'string' || !aiModelId) {
7181
return;
@@ -109,12 +119,21 @@ function vercelAiEventProcessor(event: Event): Event {
109119
* Post-process spans emitted by the Vercel AI SDK.
110120
*/
111121
function processEndedVercelAiSpan(span: SpanJSON): void {
112-
const { data: attributes, origin } = span;
122+
const { data: attributes, origin, description: name } = span;
113123

114124
if (origin !== 'auto.vercelai.otel') {
115125
return;
116126
}
117127

128+
// Set span.op if it wasn't already set during span start
129+
// This can happen when the model attribute is set too late
130+
// Check for both undefined (OTel spans without op) and 'default'
131+
if ((!span.op || span.op === 'default') && name) {
132+
const op = getSpanOpFromName(name);
133+
span.op = op;
134+
attributes['sentry.op'] = op;
135+
}
136+
118137
renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE);
119138
renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE);
120139
renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE);
@@ -204,8 +223,6 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void {
204223
}
205224

206225
function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes): void {
207-
addOriginToSpan(span, 'auto.vercelai.otel');
208-
209226
const nameWthoutAi = name.replace('ai.', '');
210227
span.setAttribute('ai.pipeline.name', nameWthoutAi);
211228
span.updateName(nameWthoutAi);
@@ -225,76 +242,33 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute
225242
}
226243
span.setAttribute('ai.streaming', name.includes('stream'));
227244

228-
// Generate Spans
229-
if (name === 'ai.generateText') {
230-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
231-
return;
232-
}
233-
234-
if (name === 'ai.generateText.doGenerate') {
235-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text');
236-
span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
237-
return;
238-
}
239-
240-
if (name === 'ai.streamText') {
241-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
242-
return;
243-
}
244-
245-
if (name === 'ai.streamText.doStream') {
246-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text');
247-
span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
248-
return;
249-
}
250-
251-
if (name === 'ai.generateObject') {
252-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
253-
return;
254-
}
255-
256-
if (name === 'ai.generateObject.doGenerate') {
257-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object');
258-
span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
259-
return;
260-
}
261-
262-
if (name === 'ai.streamObject') {
263-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
264-
return;
265-
}
266-
267-
if (name === 'ai.streamObject.doStream') {
268-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object');
269-
span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
270-
return;
271-
}
272-
273-
if (name === 'ai.embed') {
274-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
275-
return;
276-
}
277-
278-
if (name === 'ai.embed.doEmbed') {
279-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed');
280-
span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
281-
return;
245+
// Set the op based on the span name
246+
const op = getSpanOpFromName(name);
247+
if (op) {
248+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, op);
282249
}
283250

284-
if (name === 'ai.embedMany') {
285-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent');
286-
return;
287-
}
288-
289-
if (name === 'ai.embedMany.doEmbed') {
290-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many');
291-
span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`);
292-
return;
293-
}
294-
295-
if (name.startsWith('ai.stream')) {
296-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run');
297-
return;
251+
// Update span names for .do* spans to include the model ID
252+
const modelId = attributes[AI_MODEL_ID_ATTRIBUTE];
253+
switch (name) {
254+
case 'ai.generateText.doGenerate':
255+
span.updateName(`generate_text ${modelId}`);
256+
break;
257+
case 'ai.streamText.doStream':
258+
span.updateName(`stream_text ${modelId}`);
259+
break;
260+
case 'ai.generateObject.doGenerate':
261+
span.updateName(`generate_object ${modelId}`);
262+
break;
263+
case 'ai.streamObject.doStream':
264+
span.updateName(`stream_object ${modelId}`);
265+
break;
266+
case 'ai.embed.doEmbed':
267+
span.updateName(`embed ${modelId}`);
268+
break;
269+
case 'ai.embedMany.doEmbed':
270+
span.updateName(`embed_many ${modelId}`);
271+
break;
298272
}
299273
}
300274

packages/core/src/tracing/vercel-ai/utils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import type { TraceContext } from '../../types-hoist/context';
22
import type { Span, SpanAttributes, SpanJSON } from '../../types-hoist/span';
33
import {
4+
GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE,
5+
GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE,
6+
GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE,
7+
GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE,
8+
GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE,
9+
GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE,
410
GEN_AI_REQUEST_MESSAGES_ATTRIBUTE,
11+
GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE,
12+
GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE,
513
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
614
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
715
} from '../ai/gen-ai-attributes';
@@ -137,3 +145,37 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes
137145
if (messages.length) span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, getTruncatedJsonString(messages));
138146
}
139147
}
148+
149+
/**
150+
* Maps a Vercel AI span name to the corresponding Sentry op.
151+
*/
152+
export function getSpanOpFromName(name: string): string | undefined {
153+
switch (name) {
154+
case 'ai.generateText':
155+
case 'ai.streamText':
156+
case 'ai.generateObject':
157+
case 'ai.streamObject':
158+
case 'ai.embed':
159+
case 'ai.embedMany':
160+
return GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE;
161+
case 'ai.generateText.doGenerate':
162+
return GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE;
163+
case 'ai.streamText.doStream':
164+
return GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE;
165+
case 'ai.generateObject.doGenerate':
166+
return GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE;
167+
case 'ai.streamObject.doStream':
168+
return GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE;
169+
case 'ai.embed.doEmbed':
170+
return GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE;
171+
case 'ai.embedMany.doEmbed':
172+
return GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE;
173+
case 'ai.toolCall':
174+
return GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE;
175+
default:
176+
if (name.startsWith('ai.stream')) {
177+
return 'ai.run';
178+
}
179+
return name;
180+
}
181+
}

0 commit comments

Comments
 (0)