From f707db9f9971f1060d37971e9f0e6f2b58fc81a4 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 27 Oct 2026 10:12:04 -0700 Subject: [PATCH 1/3] feat(server-utils): Record AI SDK v7 runtime and tool context on Vercel AI spans On `ai` 7 the `telemetry.metadata` option is gone. Per-call metadata goes in `runtimeContext`, and the app opts keys into telemetry with `telemetry.includeRuntimeContext` (and `includeToolsContext` for tool context). The channel subscriber did not read either field, so there was no way to put request-level data on gen_ai spans. The subscriber now records each included `runtimeContext` key as `vercel.ai.settings.context.` on the operation span and on its `generate_content` and `execute_tool` child spans. It records each included `toolContext` key as `vercel.ai.toolCall.context.` on the `execute_tool` span. The `runtimeContext` names match `@ai-sdk/otel` (`ai.settings.context.*`) under the usual `ai.*` to `vercel.ai.*` renaming. `ai` before 7.0.114 published the full context to the channel and ignored the include lists. A filtered context looks the same as an unfiltered one, so the subscriber only reads context from events that carry `aiVersion`, which `ai` added in 7.0.154. Older 7.x versions record no context, so keys the app did not opt in never leave it. Fixes #24706 Fixes JS-3788 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../v6_v7/scenario-runtime-context.mjs | 91 ++++++++++ .../suites/tracing/vercelai/v6_v7/test.ts | 59 ++++++ .../vercel-ai/vercel-ai-dc-subscriber.ts | 41 ++++- .../vercel-ai/runtime-context.test.ts | 170 ++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs create mode 100644 packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs new file mode 100644 index 000000000000..5cfef0834a8e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-runtime-context.mjs @@ -0,0 +1,91 @@ +import * as Sentry from '@sentry/node'; +import { generateText, stepCountIs, streamText, tool } from 'ai'; +import { MockLanguageModelV3, simulateReadableStream } from 'ai/test'; +import { z } from 'zod'; + +const usage = { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, +}; + +const tools = { + getWeather: tool({ + description: 'Get the current weather for a location', + inputSchema: z.object({ location: z.string() }), + contextSchema: z.object({ units: z.string(), apiKey: z.string() }), + execute: async ({ location }) => `Weather in ${location}: Sunny`, + }), +}; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + let callCount = 0; + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => + callCount++ === 0 + ? { + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'Vienna' }), + }, + ], + warnings: [], + } + : { + finishReason: { unified: 'stop', raw: 'stop' }, + usage, + content: [{ type: 'text', text: 'Sunny' }], + warnings: [], + }, + }), + tools, + stopWhen: stepCountIs(3), + prompt: 'Weather in Vienna?', + runtimeContext: { requestId: 'req_generate', tenantId: 'acme', apiToken: 'secret-runtime' }, + toolsContext: { getWeather: { units: 'metric', apiKey: 'secret-tool' } }, + telemetry: { + isEnabled: true, + functionId: 'support-chat', + includeRuntimeContext: { requestId: true, tenantId: true }, + includeToolsContext: { getWeather: { units: true } }, + }, + }); + + const result = streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Hello!' }, + { type: 'text-end', id: '0' }, + { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage }, + ], + }), + }), + }), + prompt: 'Hi', + runtimeContext: { requestId: 'req_stream', apiToken: 'secret-runtime' }, + telemetry: { + isEnabled: true, + functionId: 'support-stream', + includeRuntimeContext: { requestId: true }, + }, + }); + for await (const _part of result.fullStream) { + void _part; + } + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index ea89bf21ddaf..578e2a46612b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -1429,3 +1429,62 @@ describe('Vercel AI telemetry metadata (v6)', () => { { additionalDependencies: { ai: '^6.0.0' } }, ); }); + +describe('Vercel AI runtime and tool context (v7)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmTests( + __dirname, + 'scenario-runtime-context.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('records the context keys included in telemetry as span attributes', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items.filter( + span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.vercelai', + ); + // Keys left out of `includeRuntimeContext`/`includeToolsContext` must not reach any span. + expect(JSON.stringify(spans)).not.toMatch(/secret-runtime|secret-tool|apiToken|apiKey/); + + const generateSpans = spans.filter( + span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_generate', + ); + expect(generateSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([ + 'gen_ai.execute_tool', + 'gen_ai.generate_content', + 'gen_ai.generate_content', + 'gen_ai.invoke_agent', + ]); + for (const span of generateSpans) { + expect(span.attributes['vercel.ai.settings.context.tenantId']).toEqual({ + type: 'string', + value: 'acme', + }); + } + + const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.execute_tool'); + expect(toolSpan?.attributes['vercel.ai.toolCall.context.units']).toEqual({ + type: 'string', + value: 'metric', + }); + + const streamSpans = spans.filter( + span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_stream', + ); + expect(streamSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([ + 'gen_ai.generate_content', + 'gen_ai.invoke_agent', + ]); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { ai: '^7.0.154' } }, + ); +}); diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index f7525a94e9dd..62df55778cd2 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -86,6 +86,8 @@ const VERCEL_AI_OPERATION_ID_ATTRIBUTE = 'vercel.ai.operationId'; const VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE = 'vercel.ai.model.provider'; const VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'vercel.ai.settings.maxRetries'; const VERCEL_AI_TELEMETRY_METADATA_ATTRIBUTE_PREFIX = 'vercel.ai.telemetry.metadata.'; +const VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.settings.context.'; +const VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.toolCall.context.'; // Tracks the top-level operationId (and whether it streams) per `callId` so a model-call span can // name its `doGenerate`/`doStream` operation the same way the OTel integration does. `isStream` is @@ -108,6 +110,10 @@ const toolDescriptionsByCallId = new Map>(); // before the operation's own span ends. The running token sum lives on the span's own attributes. const invokeAgentSpanByCallId = new Map(); +// Only an operation's start event carries `runtimeContext`; its model-call and tool events copy the +// resulting attributes from here. Cleared with the operation. +const runtimeContextAttributesByCallId = new Map(); + // Only top-level operations own the `callId` → operationId mapping; `step`/`languageModelCall`/ // `executeTool` share the parent's `callId`, so they must not clear it. const ROOT_OPERATION_TYPES = new Set([ @@ -140,6 +146,7 @@ export function clearOperationCallId(callId: string): void { operationIdByCallId.delete(callId); toolDescriptionsByCallId.delete(callId); invokeAgentSpanByCallId.delete(callId); + runtimeContextAttributesByCallId.delete(callId); } /** @@ -450,9 +457,17 @@ export function createSpanFromMessage( getOpenAiConversationId(event.providerOptions) ?? (ROOT_OPERATION_TYPES.has(type) ? undefined : getActiveSpanConversationId()); + const runtimeContextAttributes = + (callId ? runtimeContextAttributesByCallId.get(callId) : undefined) ?? + contextAttributes(event, event.runtimeContext, VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX); + if (callId && ROOT_OPERATION_TYPES.has(type)) { + runtimeContextAttributesByCallId.set(callId, runtimeContextAttributes); + } + const baseAttributes: SpanAttributes = { [SENTRY_ORIGIN]: ORIGIN, ...telemetryMetadataAttributes(event.telemetryMetadata), + ...runtimeContextAttributes, [GEN_AI_PROVIDER_NAME]: provider, [VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE]: provider, [GEN_AI_REQUEST_MODEL]: modelId, @@ -473,7 +488,7 @@ export function createSpanFromMessage( return buildModelCallSpan(event, baseAttributes, recordInputs, callId, modelId); case 'executeTool': - return buildToolSpan(event, recordInputs, conversationId); + return buildToolSpan(event, recordInputs, conversationId, runtimeContextAttributes); case 'embed': case 'embedMany': { // `embed` carries a single `value`; `embedMany` a `values` array — both map to the embeddings input. @@ -520,6 +535,27 @@ function telemetryMetadataAttributes(metadata: unknown): Record return attributes; } +/** + * One attribute per context key, as ``. `ai` < 7.0.114 published the full context to the + * channel, ignoring `telemetry.includeRuntimeContext`/`includeToolsContext`, and a filtered context + * looks the same as an unfiltered one. `aiVersion` (added in 7.0.154) is the only reliable sign that + * the AI SDK filtered it, so without it we record nothing. + */ +function contextAttributes(event: Record, context: unknown, prefix: string): SpanAttributes { + const attributes: SpanAttributes = {}; + if (typeof event.aiVersion !== 'string' || !isObjectLike(context)) { + return attributes; + } + for (const [key, value] of Object.entries(context)) { + const attributeValue = + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : stringify(value); + if (value !== null && attributeValue !== undefined) { + attributes[`${prefix}${key}`] = attributeValue; + } + } + return attributes; +} + /** Start a `gen_ai.` span named ` ` (or just `` when no suffix). */ function startGenAiSpan(operation: GenAiOperation, suffix: string | undefined, attributes: SpanAttributes): Span { return startInactiveSpan({ @@ -581,6 +617,7 @@ function buildToolSpan( event: Record, recordInputs: boolean, conversationId: string | undefined, + runtimeContextAttributes: SpanAttributes, ): Span { const toolCall = isObjectLike(event.toolCall) ? event.toolCall : {}; const toolName = asString(toolCall.toolName); @@ -592,6 +629,8 @@ function buildToolSpan( recordInputs && toolName ? resolveToolDescription(asString(event.callId), toolName, event.tools) : undefined; return startGenAiSpan('execute_tool', toolName, { [SENTRY_ORIGIN]: ORIGIN, + ...runtimeContextAttributes, + ...contextAttributes(event, event.toolContext, VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX), [GEN_AI_TOOL_NAME]: toolName, [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId, [GEN_AI_TOOL_DESCRIPTION]: description, diff --git a/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts new file mode 100644 index 000000000000..7ad56f709202 --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { + clearOperationId, + createSpanFromMessage, + type VercelAiChannelMessage, +} from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('Vercel AI runtime and tool context', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + function spanData(message: VercelAiChannelMessage, endedSpans: Span[]): Record { + createSpanFromMessage(message, {})?.end(); + return spanToStaticSpanJSON(endedSpans.at(-1)!).data; + } + + it('records runtimeContext keys on the operation span', () => { + const endedSpans = setupClient(); + const data = spanData( + { + type: 'generateText', + event: { + aiVersion: '7.0.154', + callId: 'call-1', + runtimeContext: { + requestId: 'req_1', + attempt: 2, + cached: false, + user: { id: 'u1' }, + tags: ['a', 'b'], + empty: null, + missing: undefined, + }, + }, + }, + endedSpans, + ); + + expect(data).toMatchObject({ + 'vercel.ai.settings.context.requestId': 'req_1', + 'vercel.ai.settings.context.attempt': 2, + 'vercel.ai.settings.context.cached': false, + 'vercel.ai.settings.context.user': '{"id":"u1"}', + 'vercel.ai.settings.context.tags': '["a","b"]', + }); + expect(data).not.toHaveProperty('vercel.ai.settings.context.empty'); + expect(data).not.toHaveProperty('vercel.ai.settings.context.missing'); + }); + + it('records runtimeContext on embed, rerank and decide spans', () => { + const endedSpans = setupClient(); + for (const type of ['embed', 'embedMany', 'rerank', 'experimental_decide'] as const) { + const data = spanData( + { type, event: { aiVersion: '7.0.154', runtimeContext: { tenantId: 'acme' } } }, + endedSpans, + ); + expect(data['vercel.ai.settings.context.tenantId']).toBe('acme'); + } + }); + + it('ignores runtimeContext from an ai version that does not report aiVersion', () => { + const endedSpans = setupClient(); + const data = spanData( + { + type: 'generateText', + event: { callId: 'call-1', runtimeContext: { apiToken: 'secret' } }, + }, + endedSpans, + ); + + expect(JSON.stringify(data)).not.toContain('secret'); + }); + + it('copies runtimeContext to model-call and tool spans of the same operation', () => { + const endedSpans = setupClient(); + const root: VercelAiChannelMessage = { + type: 'generateText', + event: { aiVersion: '7.0.154', callId: 'call-copy', runtimeContext: { tenantId: 'acme' } }, + }; + const rootSpan = createSpanFromMessage(root, {}); + + const modelCallData = spanData( + { type: 'languageModelCall', event: { aiVersion: '7.0.154', callId: 'call-copy', modelId: 'm' } }, + endedSpans, + ); + const toolData = spanData( + { + type: 'executeTool', + event: { + aiVersion: '7.0.154', + callId: 'call-copy', + toolCall: { toolName: 'getWeather', toolCallId: 'tc-1' }, + }, + }, + endedSpans, + ); + expect(modelCallData['vercel.ai.settings.context.tenantId']).toBe('acme'); + expect(toolData['vercel.ai.settings.context.tenantId']).toBe('acme'); + + clearOperationId(root); + rootSpan?.end(); + + const laterData = spanData( + { type: 'languageModelCall', event: { aiVersion: '7.0.154', callId: 'call-copy', modelId: 'm' } }, + endedSpans, + ); + expect(laterData).not.toHaveProperty('vercel.ai.settings.context.tenantId'); + }); + + it('records toolContext keys on the tool span', () => { + const endedSpans = setupClient(); + const data = spanData( + { + type: 'executeTool', + event: { + aiVersion: '7.0.154', + callId: 'call-1', + toolCall: { toolName: 'getWeather', toolCallId: 'tc-1' }, + toolContext: { units: 'metric', region: { code: 'eu' } }, + }, + }, + endedSpans, + ); + + expect(data).toMatchObject({ + 'vercel.ai.toolCall.context.units': 'metric', + 'vercel.ai.toolCall.context.region': '{"code":"eu"}', + }); + }); + + it('ignores toolContext from an ai version that does not report aiVersion', () => { + const endedSpans = setupClient(); + const data = spanData( + { + type: 'executeTool', + event: { + callId: 'call-1', + toolCall: { toolName: 'getWeather', toolCallId: 'tc-1' }, + toolContext: { apiKey: 'secret' }, + }, + }, + endedSpans, + ); + + expect(JSON.stringify(data)).not.toContain('secret'); + }); +}); From b70d1ab9bfe2303d24ce4faa1c07526786bed086 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 27 Oct 2026 10:24:23 -0700 Subject: [PATCH 2/3] fix: bugbot --- .../suites/tracing/vercelai/v6_v7/test.ts | 21 ++++++++++++------- .../vercel-ai/runtime-context.test.ts | 11 +++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index 578e2a46612b..b57c4f43cc3c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -26,7 +26,12 @@ import { SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { GEN_AI_DECIDE } from '@sentry/conventions/op'; +import { + GEN_AI_DECIDE, + GEN_AI_EXECUTE_TOOL, + GEN_AI_GENERATE_CONTENT, + GEN_AI_INVOKE_AGENT, +} from '@sentry/conventions/op'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { cleanupChildProcesses, createEsmAndCjsTests, createEsmTests } from '../../../../utils/runner'; @@ -1454,10 +1459,10 @@ describe('Vercel AI runtime and tool context (v7)', () => { span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_generate', ); expect(generateSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([ - 'gen_ai.execute_tool', - 'gen_ai.generate_content', - 'gen_ai.generate_content', - 'gen_ai.invoke_agent', + GEN_AI_EXECUTE_TOOL, + GEN_AI_GENERATE_CONTENT, + GEN_AI_GENERATE_CONTENT, + GEN_AI_INVOKE_AGENT, ]); for (const span of generateSpans) { expect(span.attributes['vercel.ai.settings.context.tenantId']).toEqual({ @@ -1466,7 +1471,7 @@ describe('Vercel AI runtime and tool context (v7)', () => { }); } - const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === 'gen_ai.execute_tool'); + const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL); expect(toolSpan?.attributes['vercel.ai.toolCall.context.units']).toEqual({ type: 'string', value: 'metric', @@ -1476,8 +1481,8 @@ describe('Vercel AI runtime and tool context (v7)', () => { span => span.attributes['vercel.ai.settings.context.requestId']?.value === 'req_stream', ); expect(streamSpans.map(span => span.attributes[SENTRY_OP]?.value).sort()).toEqual([ - 'gen_ai.generate_content', - 'gen_ai.invoke_agent', + GEN_AI_GENERATE_CONTENT, + GEN_AI_INVOKE_AGENT, ]); }, }) diff --git a/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts index 7ad56f709202..0a60dd478572 100644 --- a/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts @@ -70,16 +70,17 @@ describe('Vercel AI runtime and tool context', () => { expect(data).not.toHaveProperty('vercel.ai.settings.context.missing'); }); - it('records runtimeContext on embed, rerank and decide spans', () => { - const endedSpans = setupClient(); - for (const type of ['embed', 'embedMany', 'rerank', 'experimental_decide'] as const) { + it.each(['embed', 'embedMany', 'rerank', 'experimental_decide'] as const)( + 'records runtimeContext on %s spans', + type => { + const endedSpans = setupClient(); const data = spanData( { type, event: { aiVersion: '7.0.154', runtimeContext: { tenantId: 'acme' } } }, endedSpans, ); expect(data['vercel.ai.settings.context.tenantId']).toBe('acme'); - } - }); + }, + ); it('ignores runtimeContext from an ai version that does not report aiVersion', () => { const endedSpans = setupClient(); From b2ed834b66489a218360a069766e202d0b23b10e Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 28 Oct 2026 16:09:20 -0700 Subject: [PATCH 3/3] fix(server-utils): Align Vercel AI context attributes with conventions Rename `vercel.ai.toolCall.context.` to `vercel.ai.tool_call.context.`, because fixed segments in Sentry conventions use snake_case. Record every context value as a string, to match the `string` type of both attributes in sentry-conventions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../suites/tracing/vercelai/v6_v7/test.ts | 2 +- .../integrations/vercel-ai/vercel-ai-dc-subscriber.ts | 7 +++---- .../integrations/vercel-ai/runtime-context.test.ts | 10 +++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index b57c4f43cc3c..0ac372b9c59d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -1472,7 +1472,7 @@ describe('Vercel AI runtime and tool context (v7)', () => { } const toolSpan = generateSpans.find(span => span.attributes[SENTRY_OP]?.value === GEN_AI_EXECUTE_TOOL); - expect(toolSpan?.attributes['vercel.ai.toolCall.context.units']).toEqual({ + expect(toolSpan?.attributes['vercel.ai.tool_call.context.units']).toEqual({ type: 'string', value: 'metric', }); diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 62df55778cd2..c1daaf73fc84 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -87,7 +87,7 @@ const VERCEL_AI_MODEL_PROVIDER_ATTRIBUTE = 'vercel.ai.model.provider'; const VERCEL_AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'vercel.ai.settings.maxRetries'; const VERCEL_AI_TELEMETRY_METADATA_ATTRIBUTE_PREFIX = 'vercel.ai.telemetry.metadata.'; const VERCEL_AI_SETTINGS_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.settings.context.'; -const VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.toolCall.context.'; +const VERCEL_AI_TOOL_CALL_CONTEXT_ATTRIBUTE_PREFIX = 'vercel.ai.tool_call.context.'; // Tracks the top-level operationId (and whether it streams) per `callId` so a model-call span can // name its `doGenerate`/`doStream` operation the same way the OTel integration does. `isStream` is @@ -536,7 +536,7 @@ function telemetryMetadataAttributes(metadata: unknown): Record } /** - * One attribute per context key, as ``. `ai` < 7.0.114 published the full context to the + * One string attribute per context key, as ``. `ai` < 7.0.114 published the full context to the * channel, ignoring `telemetry.includeRuntimeContext`/`includeToolsContext`, and a filtered context * looks the same as an unfiltered one. `aiVersion` (added in 7.0.154) is the only reliable sign that * the AI SDK filtered it, so without it we record nothing. @@ -547,8 +547,7 @@ function contextAttributes(event: Record, context: unknown, pre return attributes; } for (const [key, value] of Object.entries(context)) { - const attributeValue = - typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : stringify(value); + const attributeValue = stringify(value); if (value !== null && attributeValue !== undefined) { attributes[`${prefix}${key}`] = attributeValue; } diff --git a/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts index 0a60dd478572..e8cf74b37080 100644 --- a/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts +++ b/packages/server-utils/test/integrations/vercel-ai/runtime-context.test.ts @@ -37,7 +37,7 @@ describe('Vercel AI runtime and tool context', () => { return spanToStaticSpanJSON(endedSpans.at(-1)!).data; } - it('records runtimeContext keys on the operation span', () => { + it('records runtimeContext keys as strings on the operation span', () => { const endedSpans = setupClient(); const data = spanData( { @@ -61,8 +61,8 @@ describe('Vercel AI runtime and tool context', () => { expect(data).toMatchObject({ 'vercel.ai.settings.context.requestId': 'req_1', - 'vercel.ai.settings.context.attempt': 2, - 'vercel.ai.settings.context.cached': false, + 'vercel.ai.settings.context.attempt': '2', + 'vercel.ai.settings.context.cached': 'false', 'vercel.ai.settings.context.user': '{"id":"u1"}', 'vercel.ai.settings.context.tags': '["a","b"]', }); @@ -147,8 +147,8 @@ describe('Vercel AI runtime and tool context', () => { ); expect(data).toMatchObject({ - 'vercel.ai.toolCall.context.units': 'metric', - 'vercel.ai.toolCall.context.region': '{"code":"eu"}', + 'vercel.ai.tool_call.context.units': 'metric', + 'vercel.ai.tool_call.context.region': '{"code":"eu"}', }); });