Skip to content
Draft
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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ async function run() {
prompt: 'Where is the first span?',
});

// This span should have input and output prompts attached because telemetry is explicitly enabled.
// This span should have input and output prompts attached because recording is explicitly enabled.
// (v6 enables recording implicitly from `isEnabled: true`; v7's diagnostics channel only carries
// the explicit `recordInputs`/`recordOutputs` flags, so we set them here to exercise both.)
await generateText({
experimental_telemetry: { isEnabled: true },
experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true },
model: new MockLanguageModelV3({
doGenerate: async () => ({
finishReason: { unified: 'stop', raw: 'stop' },
Expand Down

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/node/src/integrations/tracing/vercelai/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Client, IntegrationFn } from '@sentry/core';
import { addVercelAiProcessors, defineIntegration } from '@sentry/core';
import { generateInstrumentOnce, type modulesIntegration } from '@sentry/node-core';
import { tracingChannel as otelTracingChannel } from '@sentry/opentelemetry/tracing-channel';
import { subscribeVercelAiTracingChannel } from '@sentry/server-utils';
import { INTEGRATION_NAME } from './constants';
import { SentryVercelAiInstrumentation } from './instrumentation';
import type { VercelAiOptions } from './types';
Expand All @@ -24,6 +26,14 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => {
options,
setupOnce() {
instrumentation = instrumentVercelAi();

// Subscribe to the `ai` SDK's native telemetry tracing channel (ai >= 7). This emits
// fully-formed `gen_ai.*` spans directly and is a no-op on versions that don't publish to the
// channel, so it is always safe alongside the OTel-based instrumentation above. We pass
// `@sentry/opentelemetry/tracing-channel` as the factory so spans become the active OTel
// context via `bindStore` (giving correct parent/child nesting). That factory needs the Sentry
// OTel context manager, which `initOpenTelemetry()` registers after `setupOnce`, so defer a tick.
void Promise.resolve().then(() => subscribeVercelAiTracingChannel(otelTracingChannel));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

V7 never registers Vercel processors

High Severity

With ai v7, OTel instrumentation no longer patches the package, so callWhenPatched never runs. addVercelAiProcessors is only registered when shouldForce is true at afterAllSetup. If the Modules integration does not list ai then (typical for ESM before import or when ai is missing from cwd package.json dependencies), v7 channel spans are emitted but transaction processors never run—no tool descriptions, parent token rollup, or related enrichment.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 21822ce. Configure here.

},
afterAllSetup(client) {
// Auto-detect if we should force the integration when running with 'ai' package available
Expand Down
143 changes: 143 additions & 0 deletions packages/node/test/integrations/tracing/vercelai/channel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { tracingChannel } from 'node:diagnostics_channel';
import type { SpanJSON, TransactionEvent } from '@sentry/core';
import { tracingChannel as otelTracingChannel } from '@sentry/opentelemetry/tracing-channel';
import { subscribeVercelAiTracingChannel } from '@sentry/server-utils';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import * as Sentry from '../../../../src';
import { cleanupOtel, mockSdkInit } from '../../../helpers/mockSdkInit';

const channel = tracingChannel<Record<string, unknown>, Record<string, unknown>>('ai:telemetry');

const transactions: TransactionEvent[] = [];

describe('Vercel AI telemetry tracing channel', () => {
beforeAll(() => {
mockSdkInit({
tracesSampleRate: 1,
dataCollection: { genAI: { inputs: true, outputs: true } },
beforeSendTransaction: event => {
transactions.push(event);
return null;
},
});
// The OTel context manager is registered by `mockSdkInit` above, so we can subscribe directly.
subscribeVercelAiTracingChannel(otelTracingChannel);
});

afterAll(() => {
cleanupOtel();
});

beforeEach(() => {
transactions.length = 0;
});

it('builds a parented, fully-formed gen_ai span tree from the ai:telemetry channel', async () => {
await channel.tracePromise(
async () => {
await channel.tracePromise(
async () => ({
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
finishReason: 'stop',
text: 'Hi there',
response: { id: 'resp-1', modelId: 'gpt-4o-2024' },
}),
{ type: 'languageModelCall', event: { callId: 'call-1', provider: 'openai', modelId: 'gpt-4o' } },
);

await channel.tracePromise(async () => ({ output: { temperature: 70 } }), {
type: 'executeTool',
event: {
callId: 'call-1',
toolCallId: 'tool-1',
toolCall: { toolName: 'getWeather', toolCallId: 'tool-1', input: { city: 'SF' } },
},
});

return { usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 }, finishReason: 'stop' };
},
{
type: 'generateText',
event: {
callId: 'call-1',
provider: 'openai',
modelId: 'gpt-4o',
functionId: 'my-agent',
messages: [{ role: 'user', content: 'Hello' }],
},
},
);

await Sentry.getClient()!.flush();

expect(transactions).toHaveLength(1);
const transaction = transactions[0]!;

expect(transaction.transaction).toBe('invoke_agent my-agent');
expect(transaction.contexts?.trace).toEqual(
expect.objectContaining({
op: 'gen_ai.invoke_agent',
origin: 'auto.vercelai.channel',
data: expect.objectContaining({
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.response.streaming': false,
'gen_ai.function_id': 'my-agent',
'gen_ai.input.messages': JSON.stringify([{ role: 'user', content: 'Hello' }]),
}),
}),
);

const rootSpanId = transaction.contexts?.trace?.span_id;
const spans = transaction.spans ?? [];
const modelCallSpan = spans.find((span: SpanJSON) => span.op === 'gen_ai.generate_content');
const toolSpan = spans.find((span: SpanJSON) => span.op === 'gen_ai.execute_tool');

// Model call and tool call are siblings parented under the invoke_agent span.
expect(modelCallSpan?.parent_span_id).toBe(rootSpanId);
expect(toolSpan?.parent_span_id).toBe(rootSpanId);

expect(modelCallSpan?.description).toBe('generate_content gpt-4o');
expect(modelCallSpan?.data).toEqual(
expect.objectContaining({
'gen_ai.operation.name': 'generate_content',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.usage.input_tokens': 10,
'gen_ai.usage.output_tokens': 20,
'gen_ai.usage.total_tokens': 30,
'gen_ai.response.finish_reasons': JSON.stringify(['stop']),
'gen_ai.response.id': 'resp-1',
'gen_ai.response.model': 'gpt-4o-2024',
'gen_ai.output.messages':
'[{"role":"assistant","parts":[{"type":"text","content":"Hi there"}],"finish_reason":"stop"}]',
}),
);

expect(toolSpan?.description).toBe('execute_tool getWeather');
expect(toolSpan?.data).toEqual(
expect.objectContaining({
'gen_ai.operation.name': 'execute_tool',
'gen_ai.tool.type': 'function',
'gen_ai.tool.name': 'getWeather',
'gen_ai.tool.call.id': 'tool-1',
'gen_ai.tool.input': JSON.stringify({ city: 'SF' }),
'gen_ai.tool.output': JSON.stringify({ temperature: 70 }),
}),
);
});

it('marks the span as errored when the traced operation rejects', async () => {
await expect(
channel.tracePromise(async () => Promise.reject(new Error('boom')), {
type: 'generateText',
event: { callId: 'call-err', provider: 'openai', modelId: 'gpt-4o' },
}),
).rejects.toThrow('boom');

await Sentry.getClient()!.flush();

expect(transactions).toHaveLength(1);
expect(transactions[0]!.contexts?.trace?.status).toBe('internal_error');
});
});
8 changes: 8 additions & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ export type {
RedisTracingChannelFactory,
RedisTracingChannelSubscribers,
} from './redis/redis-dc-subscriber';
export { AI_SDK_TELEMETRY_TRACING_CHANNEL, subscribeVercelAiTracingChannel } from './vercel-ai/vercel-ai-dc-subscriber';
export type {
VercelAiChannelMessage,
VercelAiTracingChannel,
VercelAiTracingChannelContextWithSpan,
VercelAiTracingChannelFactory,
VercelAiTracingChannelSubscribers,
} from './vercel-ai/vercel-ai-dc-subscriber';
Loading
Loading