From 60d9f627d28195034dcf7e0929e12df46d67ffb6 Mon Sep 17 00:00:00 2001 From: Kenta Iwasaki Date: Sun, 15 Feb 2026 02:07:49 +0800 Subject: [PATCH 1/8] fix(vercel-ai): prevent tool call span map memory leak Tool calls were stored in a global map and only cleaned up on tool errors, causing unbounded retention in tool-heavy apps (and potential OOMs when inputs/outputs were recorded). Store only span context in a bounded LRU cache and clean up on successful tool results; add tests for caching/eviction. --- .../core/src/tracing/vercel-ai/constants.ts | 7 +- packages/core/src/tracing/vercel-ai/index.ts | 5 +- packages/core/src/tracing/vercel-ai/types.ts | 5 + packages/core/src/tracing/vercel-ai/utils.ts | 6 +- .../vercel-ai-tool-call-span-map.test.ts | 108 +++++++++++++++ .../tracing/vercelai/instrumentation.ts | 130 ++++++++++-------- 6 files changed, 197 insertions(+), 64 deletions(-) create mode 100644 packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts diff --git a/packages/core/src/tracing/vercel-ai/constants.ts b/packages/core/src/tracing/vercel-ai/constants.ts index 82baf0312d7c..5b4da3c9e773 100644 --- a/packages/core/src/tracing/vercel-ai/constants.ts +++ b/packages/core/src/tracing/vercel-ai/constants.ts @@ -1,8 +1,9 @@ -import type { Span } from '../../types-hoist/span'; +import type { ToolCallSpanContext } from './types'; -// Global Map to track tool call IDs to their corresponding spans +// Global map to track tool call IDs to their corresponding span contexts. // This allows us to capture tool errors and link them to the correct span -export const toolCallSpanMap = new Map(); +// without keeping full Span objects (and their potentially large attributes) alive. +export const toolCallSpanMap = new Map(); // Operation sets for efficient mapping to OpenTelemetry semantic convention values export const INVOKE_AGENT_OPS = new Set([ diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index d3c4b036e228..233762f4caa2 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -232,12 +232,13 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void { renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, GEN_AI_TOOL_NAME_ATTRIBUTE); renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, GEN_AI_TOOL_CALL_ID_ATTRIBUTE); - // Store the span in our global map using the tool call ID + // Store the span context in our global map using the tool call ID. // This allows us to capture tool errors and link them to the correct span + // without retaining the full Span object in memory. const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]; if (typeof toolCallId === 'string') { - toolCallSpanMap.set(toolCallId, span); + toolCallSpanMap.set(toolCallId, span.spanContext()); } // https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type diff --git a/packages/core/src/tracing/vercel-ai/types.ts b/packages/core/src/tracing/vercel-ai/types.ts index 03f22c415001..754ec53551ce 100644 --- a/packages/core/src/tracing/vercel-ai/types.ts +++ b/packages/core/src/tracing/vercel-ai/types.ts @@ -2,3 +2,8 @@ export interface TokenSummary { inputTokens: number; outputTokens: number; } + +export interface ToolCallSpanContext { + traceId: string; + spanId: string; +} diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index b5f1b6c68352..86e17cc39cd9 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -18,7 +18,7 @@ import { } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; import { toolCallSpanMap } from './constants'; -import type { TokenSummary } from './types'; +import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; /** @@ -75,9 +75,9 @@ export function applyAccumulatedTokens( } /** - * Get the span associated with a tool call ID + * Get the span context associated with a tool call ID. */ -export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined { +export function _INTERNAL_getSpanForToolCallId(toolCallId: string): ToolCallSpanContext | undefined { return toolCallSpanMap.get(toolCallId); } diff --git a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts new file mode 100644 index 000000000000..e53e2e4326fe --- /dev/null +++ b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; +import { toolCallSpanMap } from '../../../src/tracing/vercel-ai/constants'; +import { _INTERNAL_cleanupToolCallSpan, _INTERNAL_getSpanForToolCallId } from '../../../src/tracing/vercel-ai/utils'; +import { + AI_TOOL_CALL_ID_ATTRIBUTE, + AI_TOOL_CALL_NAME_ATTRIBUTE, +} from '../../../src/tracing/vercel-ai/vercel-ai-attributes'; +import type { SpanAttributes, SpanAttributeValue, SpanTimeInput } from '../../../src/types-hoist/span'; +import type { SpanStatus } from '../../../src/types-hoist/spanStatus'; +import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +function createToolCallSpan(params: { + toolCallId: string; + toolName: string; + traceId: string; + spanId: string; +}): OpenTelemetrySdkTraceBaseSpan { + const attributes: SpanAttributes = { + [AI_TOOL_CALL_ID_ATTRIBUTE]: params.toolCallId, + [AI_TOOL_CALL_NAME_ATTRIBUTE]: params.toolName, + }; + + const startTime: SpanTimeInput = [0, 0]; + const endTime: SpanTimeInput = [0, 0]; + const status: SpanStatus = { code: 0 }; + + const span: OpenTelemetrySdkTraceBaseSpan = { + attributes, + startTime, + endTime, + name: 'ai.toolCall', + status, + spanContext: () => ({ + traceId: params.traceId, + spanId: params.spanId, + traceFlags: 1, + }), + end: () => undefined, + setAttribute: (key: string, value: SpanAttributeValue | undefined) => { + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[key]; + } else { + attributes[key] = value; + } + return span; + }, + setAttributes: (nextAttributes: SpanAttributes) => { + for (const key of Object.keys(nextAttributes)) { + const value = nextAttributes[key]; + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[key]; + } else { + attributes[key] = value; + } + } + return span; + }, + setStatus: (nextStatus: SpanStatus) => { + span.status = nextStatus; + return span; + }, + updateName: (name: string) => { + span.name = name; + return span; + }, + isRecording: () => true, + addEvent: () => span, + addLink: () => span, + addLinks: () => span, + recordException: () => undefined, + }; + + return span; +} + +describe('vercel-ai tool call span context map', () => { + beforeEach(() => { + toolCallSpanMap.clear(); + }); + + it('stores toolCallId -> span context on spanStart', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); + const client = new TestClient(options); + client.init(); + addVercelAiProcessors(client); + + const span = createToolCallSpan({ + toolCallId: 'tool-call-1', + toolName: 'bash', + traceId: 'trace-id-1', + spanId: 'span-id-1', + }); + + client.emit('spanStart', span); + + expect(_INTERNAL_getSpanForToolCallId('tool-call-1')).toMatchObject({ + traceId: 'trace-id-1', + spanId: 'span-id-1', + }); + + _INTERNAL_cleanupToolCallSpan('tool-call-1'); + expect(_INTERNAL_getSpanForToolCallId('tool-call-1')).toBeUndefined(); + }); +}); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 19e6a2798b01..bd6dd4ac5009 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,6 +1,5 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; import { _INTERNAL_cleanupToolCallSpan, _INTERNAL_getSpanForToolCallId, @@ -43,33 +42,46 @@ interface RecordingOptions { recordOutputs?: boolean; } -interface ToolError { - type: 'tool-error' | 'tool-result' | 'tool-call'; +interface ToolErrorPart { + type: 'tool-error'; toolCallId: string; toolName: string; - input?: { - [key: string]: unknown; - }; error: Error; - dynamic?: boolean; } -function isToolError(obj: unknown): obj is ToolError { +interface ToolResultPart { + type: 'tool-result'; + toolCallId: string; + toolName: string; +} + +function isToolErrorPart(obj: unknown): obj is ToolErrorPart { if (typeof obj !== 'object' || obj === null) { return false; } const candidate = obj as Record; return ( - 'type' in candidate && - 'error' in candidate && - 'toolName' in candidate && - 'toolCallId' in candidate && candidate.type === 'tool-error' && + typeof candidate.toolName === 'string' && + typeof candidate.toolCallId === 'string' && candidate.error instanceof Error ); } +function isToolResultPart(obj: unknown): obj is ToolResultPart { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const candidate = obj as Record; + return ( + candidate.type === 'tool-result' && + typeof candidate.toolName === 'string' && + typeof candidate.toolCallId === 'string' + ); +} + /** * Check for tool errors in the result and capture them * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content @@ -79,59 +91,65 @@ function checkResultForToolErrors(result: unknown): void { return; } - const resultObj = result as { content: Array }; + const resultObj = result as { content: unknown }; if (!Array.isArray(resultObj.content)) { return; } for (const item of resultObj.content) { - if (isToolError(item)) { - // Try to get the span associated with this tool call ID - const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span; - - if (associatedSpan) { - // We have the span, so link the error using span and trace IDs from the span - const spanContext = associatedSpan.spanContext(); - - withScope(scope => { - // Set the span and trace context for proper linking - scope.setContext('trace', { - trace_id: spanContext.traceId, - span_id: spanContext.spanId, - }); - - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); - - scope.setLevel('error'); - - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, - }); + // Successful tool calls should not keep toolCallId -> span context mappings alive. + if (isToolResultPart(item)) { + _INTERNAL_cleanupToolCallSpan(item.toolCallId); + continue; + } + + if (!isToolErrorPart(item)) { + continue; + } + + // Try to get the span context associated with this tool call ID + const spanContext = _INTERNAL_getSpanForToolCallId(item.toolCallId); + + if (spanContext) { + // We have a span context, so link the error using span and trace IDs from the span + withScope(scope => { + // Set the span and trace context for proper linking + scope.setContext('trace', { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, }); - // Clean up the span mapping since we've processed this tool error - // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. - _INTERNAL_cleanupToolCallSpan(item.toolCallId); - } else { - // Fallback: capture without span linking - withScope(scope => { - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); - scope.setLevel('error'); - - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, - }); + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, }); - } + }); + } else { + // Fallback: capture without span linking + withScope(scope => { + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); + }); } + + // Clean up the span mapping since we've processed this tool error + // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. + _INTERNAL_cleanupToolCallSpan(item.toolCallId); } } From 82d683c4cc0e06b3410f15122668a9f43f0acc13 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 15:02:36 +0100 Subject: [PATCH 2/8] some renames --- packages/core/src/index.ts | 2 +- packages/core/src/tracing/vercel-ai/constants.ts | 2 +- packages/core/src/tracing/vercel-ai/index.ts | 4 ++-- packages/core/src/tracing/vercel-ai/utils.ts | 8 ++++---- .../lib/tracing/vercel-ai-tool-call-span-map.test.ts | 10 +++++----- .../integrations/tracing/vercelai/instrumentation.ts | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d76917bf0cdd..14c6b9d59c9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,7 +146,7 @@ export * as metrics from './metrics/public-api'; export type { MetricOptions } from './metrics/public-api'; export { createConsolaReporter } from './integrations/consola'; export { addVercelAiProcessors } from './tracing/vercel-ai'; -export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils'; +export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils'; export { instrumentOpenAiClient } from './tracing/openai'; export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants'; export { instrumentAnthropicAiClient } from './tracing/anthropic-ai'; diff --git a/packages/core/src/tracing/vercel-ai/constants.ts b/packages/core/src/tracing/vercel-ai/constants.ts index 5b4da3c9e773..3c43b80ac7a7 100644 --- a/packages/core/src/tracing/vercel-ai/constants.ts +++ b/packages/core/src/tracing/vercel-ai/constants.ts @@ -3,7 +3,7 @@ import type { ToolCallSpanContext } from './types'; // Global map to track tool call IDs to their corresponding span contexts. // This allows us to capture tool errors and link them to the correct span // without keeping full Span objects (and their potentially large attributes) alive. -export const toolCallSpanMap = new Map(); +export const toolCallSpanContextMap = new Map(); // Operation sets for efficient mapping to OpenTelemetry semantic convention values export const INVOKE_AGENT_OPS = new Set([ diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 233762f4caa2..c7bc3235d2f4 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -19,7 +19,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanMap } from './constants'; +import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanContextMap } from './constants'; import type { TokenSummary } from './types'; import { accumulateTokensForParent, @@ -238,7 +238,7 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void { const toolCallId = attributes[GEN_AI_TOOL_CALL_ID_ATTRIBUTE]; if (typeof toolCallId === 'string') { - toolCallSpanMap.set(toolCallId, span.spanContext()); + toolCallSpanContextMap.set(toolCallId, span.spanContext()); } // https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index 86e17cc39cd9..f2f61ddf32d1 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -17,7 +17,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; -import { toolCallSpanMap } from './constants'; +import { toolCallSpanContextMap } from './constants'; import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; @@ -77,15 +77,15 @@ export function applyAccumulatedTokens( /** * Get the span context associated with a tool call ID. */ -export function _INTERNAL_getSpanForToolCallId(toolCallId: string): ToolCallSpanContext | undefined { - return toolCallSpanMap.get(toolCallId); +export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolCallSpanContext | undefined { + return toolCallSpanContextMap.get(toolCallId); } /** * Clean up the span mapping for a tool call ID */ export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void { - toolCallSpanMap.delete(toolCallId); + toolCallSpanContextMap.delete(toolCallId); } /** diff --git a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts index e53e2e4326fe..d5e8954df7b1 100644 --- a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts +++ b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; -import { toolCallSpanMap } from '../../../src/tracing/vercel-ai/constants'; -import { _INTERNAL_cleanupToolCallSpan, _INTERNAL_getSpanForToolCallId } from '../../../src/tracing/vercel-ai/utils'; +import { toolCallSpanContextMap } from '../../../src/tracing/vercel-ai/constants'; +import { _INTERNAL_cleanupToolCallSpan, _INTERNAL_getSpanContextForToolCallId } from '../../../src/tracing/vercel-ai/utils'; import { AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, @@ -79,7 +79,7 @@ function createToolCallSpan(params: { describe('vercel-ai tool call span context map', () => { beforeEach(() => { - toolCallSpanMap.clear(); + toolCallSpanContextMap.clear(); }); it('stores toolCallId -> span context on spanStart', () => { @@ -97,12 +97,12 @@ describe('vercel-ai tool call span context map', () => { client.emit('spanStart', span); - expect(_INTERNAL_getSpanForToolCallId('tool-call-1')).toMatchObject({ + expect(_INTERNAL_getSpanContextForToolCallId('tool-call-1')).toMatchObject({ traceId: 'trace-id-1', spanId: 'span-id-1', }); _INTERNAL_cleanupToolCallSpan('tool-call-1'); - expect(_INTERNAL_getSpanForToolCallId('tool-call-1')).toBeUndefined(); + expect(_INTERNAL_getSpanContextForToolCallId('tool-call-1')).toBeUndefined(); }); }); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index bd6dd4ac5009..028f88b30969 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -2,7 +2,7 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@op import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { _INTERNAL_cleanupToolCallSpan, - _INTERNAL_getSpanForToolCallId, + _INTERNAL_getSpanContextForToolCallId, addNonEnumerableProperty, captureException, getActiveSpan, @@ -108,7 +108,7 @@ function checkResultForToolErrors(result: unknown): void { } // Try to get the span context associated with this tool call ID - const spanContext = _INTERNAL_getSpanForToolCallId(item.toolCallId); + const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); if (spanContext) { // We have a span context, so link the error using span and trace IDs from the span From 92140347c9155b63ab56be5be4ac66fff62d1383 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 15:12:47 +0100 Subject: [PATCH 3/8] rename --- packages/core/src/index.ts | 2 +- packages/core/src/tracing/vercel-ai/utils.ts | 2 +- .../test/lib/tracing/vercel-ai-tool-call-span-map.test.ts | 4 ++-- .../src/integrations/tracing/vercelai/instrumentation.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 14c6b9d59c9a..bfe9216d6874 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,7 +146,7 @@ export * as metrics from './metrics/public-api'; export type { MetricOptions } from './metrics/public-api'; export { createConsolaReporter } from './integrations/consola'; export { addVercelAiProcessors } from './tracing/vercel-ai'; -export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpan } from './tracing/vercel-ai/utils'; +export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils'; export { instrumentOpenAiClient } from './tracing/openai'; export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants'; export { instrumentAnthropicAiClient } from './tracing/anthropic-ai'; diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index f2f61ddf32d1..139d75a241ee 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -84,7 +84,7 @@ export function _INTERNAL_getSpanContextForToolCallId(toolCallId: string): ToolC /** * Clean up the span mapping for a tool call ID */ -export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void { +export function _INTERNAL_cleanupToolCallSpanContext(toolCallId: string): void { toolCallSpanContextMap.delete(toolCallId); } diff --git a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts index d5e8954df7b1..b4ef39ecbd4b 100644 --- a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts +++ b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; import { toolCallSpanContextMap } from '../../../src/tracing/vercel-ai/constants'; -import { _INTERNAL_cleanupToolCallSpan, _INTERNAL_getSpanContextForToolCallId } from '../../../src/tracing/vercel-ai/utils'; +import { _INTERNAL_cleanupToolCallSpanContext, _INTERNAL_getSpanContextForToolCallId } from '../../../src/tracing/vercel-ai/utils'; import { AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, @@ -102,7 +102,7 @@ describe('vercel-ai tool call span context map', () => { spanId: 'span-id-1', }); - _INTERNAL_cleanupToolCallSpan('tool-call-1'); + _INTERNAL_cleanupToolCallSpanContext('tool-call-1'); expect(_INTERNAL_getSpanContextForToolCallId('tool-call-1')).toBeUndefined(); }); }); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 028f88b30969..f83eb4b6cce7 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,7 +1,7 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { - _INTERNAL_cleanupToolCallSpan, + _INTERNAL_cleanupToolCallSpanContextContext, _INTERNAL_getSpanContextForToolCallId, addNonEnumerableProperty, captureException, @@ -99,7 +99,7 @@ function checkResultForToolErrors(result: unknown): void { for (const item of resultObj.content) { // Successful tool calls should not keep toolCallId -> span context mappings alive. if (isToolResultPart(item)) { - _INTERNAL_cleanupToolCallSpan(item.toolCallId); + _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); continue; } @@ -149,7 +149,7 @@ function checkResultForToolErrors(result: unknown): void { // Clean up the span mapping since we've processed this tool error // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. - _INTERNAL_cleanupToolCallSpan(item.toolCallId); + _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); } } From a10e64701468437f0cf7e18c40a55027dc697d8f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 15:47:59 +0100 Subject: [PATCH 4/8] fix typo --- .../node/src/integrations/tracing/vercelai/instrumentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index f83eb4b6cce7..9ab9670c84f1 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,7 +1,7 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { - _INTERNAL_cleanupToolCallSpanContextContext, + _INTERNAL_cleanupToolCallSpanContext, _INTERNAL_getSpanContextForToolCallId, addNonEnumerableProperty, captureException, From 744d63d9407a5f61298b9419ed4dc1461a47b1fe Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 16:17:06 +0100 Subject: [PATCH 5/8] remove not needed stuff and update tests --- packages/core/src/index.ts | 1 + .../vercel-ai-tool-call-span-map.test.ts | 108 ---------------- .../tracing/vercelai/instrumentation.ts | 119 +++++++++--------- .../tracing/vercelai/instrumentation.test.ts | 71 ++++++++++- 4 files changed, 127 insertions(+), 172 deletions(-) delete mode 100644 packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bfe9216d6874..3eeb316780cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,6 +147,7 @@ export type { MetricOptions } from './metrics/public-api'; export { createConsolaReporter } from './integrations/consola'; export { addVercelAiProcessors } from './tracing/vercel-ai'; export { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_cleanupToolCallSpanContext } from './tracing/vercel-ai/utils'; +export { toolCallSpanContextMap as _INTERNAL_toolCallSpanContextMap } from './tracing/vercel-ai/constants'; export { instrumentOpenAiClient } from './tracing/openai'; export { OPENAI_INTEGRATION_NAME } from './tracing/openai/constants'; export { instrumentAnthropicAiClient } from './tracing/anthropic-ai'; diff --git a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts b/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts deleted file mode 100644 index b4ef39ecbd4b..000000000000 --- a/packages/core/test/lib/tracing/vercel-ai-tool-call-span-map.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; -import { toolCallSpanContextMap } from '../../../src/tracing/vercel-ai/constants'; -import { _INTERNAL_cleanupToolCallSpanContext, _INTERNAL_getSpanContextForToolCallId } from '../../../src/tracing/vercel-ai/utils'; -import { - AI_TOOL_CALL_ID_ATTRIBUTE, - AI_TOOL_CALL_NAME_ATTRIBUTE, -} from '../../../src/tracing/vercel-ai/vercel-ai-attributes'; -import type { SpanAttributes, SpanAttributeValue, SpanTimeInput } from '../../../src/types-hoist/span'; -import type { SpanStatus } from '../../../src/types-hoist/spanStatus'; -import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; -import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; - -function createToolCallSpan(params: { - toolCallId: string; - toolName: string; - traceId: string; - spanId: string; -}): OpenTelemetrySdkTraceBaseSpan { - const attributes: SpanAttributes = { - [AI_TOOL_CALL_ID_ATTRIBUTE]: params.toolCallId, - [AI_TOOL_CALL_NAME_ATTRIBUTE]: params.toolName, - }; - - const startTime: SpanTimeInput = [0, 0]; - const endTime: SpanTimeInput = [0, 0]; - const status: SpanStatus = { code: 0 }; - - const span: OpenTelemetrySdkTraceBaseSpan = { - attributes, - startTime, - endTime, - name: 'ai.toolCall', - status, - spanContext: () => ({ - traceId: params.traceId, - spanId: params.spanId, - traceFlags: 1, - }), - end: () => undefined, - setAttribute: (key: string, value: SpanAttributeValue | undefined) => { - if (value === undefined) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete attributes[key]; - } else { - attributes[key] = value; - } - return span; - }, - setAttributes: (nextAttributes: SpanAttributes) => { - for (const key of Object.keys(nextAttributes)) { - const value = nextAttributes[key]; - if (value === undefined) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete attributes[key]; - } else { - attributes[key] = value; - } - } - return span; - }, - setStatus: (nextStatus: SpanStatus) => { - span.status = nextStatus; - return span; - }, - updateName: (name: string) => { - span.name = name; - return span; - }, - isRecording: () => true, - addEvent: () => span, - addLink: () => span, - addLinks: () => span, - recordException: () => undefined, - }; - - return span; -} - -describe('vercel-ai tool call span context map', () => { - beforeEach(() => { - toolCallSpanContextMap.clear(); - }); - - it('stores toolCallId -> span context on spanStart', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); - const client = new TestClient(options); - client.init(); - addVercelAiProcessors(client); - - const span = createToolCallSpan({ - toolCallId: 'tool-call-1', - toolName: 'bash', - traceId: 'trace-id-1', - spanId: 'span-id-1', - }); - - client.emit('spanStart', span); - - expect(_INTERNAL_getSpanContextForToolCallId('tool-call-1')).toMatchObject({ - traceId: 'trace-id-1', - spanId: 'span-id-1', - }); - - _INTERNAL_cleanupToolCallSpanContext('tool-call-1'); - expect(_INTERNAL_getSpanContextForToolCallId('tool-call-1')).toBeUndefined(); - }); -}); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 9ab9670c84f1..3e569275510a 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { Span } from '@sentry/core'; import { _INTERNAL_cleanupToolCallSpanContext, _INTERNAL_getSpanContextForToolCallId, @@ -42,114 +43,108 @@ interface RecordingOptions { recordOutputs?: boolean; } -interface ToolErrorPart { - type: 'tool-error'; +interface ToolError { + type: 'tool-error' | 'tool-result' | 'tool-call'; toolCallId: string; toolName: string; + input?: { + [key: string]: unknown; + }; error: Error; + dynamic?: boolean; } -interface ToolResultPart { - type: 'tool-result'; - toolCallId: string; - toolName: string; -} - -function isToolErrorPart(obj: unknown): obj is ToolErrorPart { +function isToolError(obj: unknown): obj is ToolError { if (typeof obj !== 'object' || obj === null) { return false; } const candidate = obj as Record; return ( + 'type' in candidate && + 'error' in candidate && + 'toolName' in candidate && + 'toolCallId' in candidate && candidate.type === 'tool-error' && - typeof candidate.toolName === 'string' && - typeof candidate.toolCallId === 'string' && candidate.error instanceof Error ); } -function isToolResultPart(obj: unknown): obj is ToolResultPart { +function isToolResult(obj: unknown): obj is { type: 'tool-result'; toolCallId: string } { if (typeof obj !== 'object' || obj === null) { return false; } const candidate = obj as Record; - return ( - candidate.type === 'tool-result' && - typeof candidate.toolName === 'string' && - typeof candidate.toolCallId === 'string' - ); + return candidate.type === 'tool-result' && typeof candidate.toolCallId === 'string'; } /** * Check for tool errors in the result and capture them * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content */ -function checkResultForToolErrors(result: unknown): void { +export function _INTERNAL_checkResultForToolErrors(result: unknown): void { if (typeof result !== 'object' || result === null || !('content' in result)) { return; } - const resultObj = result as { content: unknown }; + const resultObj = result as { content: Array }; if (!Array.isArray(resultObj.content)) { return; } for (const item of resultObj.content) { - // Successful tool calls should not keep toolCallId -> span context mappings alive. - if (isToolResultPart(item)) { + // Clean up successful tool call entries to prevent memory leaks + if (isToolResult(item)) { _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); continue; } - if (!isToolErrorPart(item)) { - continue; - } - - // Try to get the span context associated with this tool call ID - const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); + if (isToolError(item)) { + // Try to get the span context associated with this tool call ID + const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); - if (spanContext) { - // We have a span context, so link the error using span and trace IDs from the span - withScope(scope => { - // Set the span and trace context for proper linking - scope.setContext('trace', { - trace_id: spanContext.traceId, - span_id: spanContext.spanId, - }); + if (spanContext) { + // We have the span context, so link the error using span and trace IDs + withScope(scope => { + // Set the span and trace context for proper linking + scope.setContext('trace', { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + }); - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); - scope.setLevel('error'); + scope.setLevel('error'); - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); }); - }); - } else { - // Fallback: capture without span linking - withScope(scope => { - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); - scope.setLevel('error'); - - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, + + // Clean up the span mapping since we've processed this tool error + // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. + _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); + } else { + // Fallback: capture without span linking + withScope(scope => { + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); }); - }); + } } - - // Clean up the span mapping since we've processed this tool error - // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. - _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); } } @@ -270,7 +265,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }, () => {}, result => { - checkResultForToolErrors(result); + _INTERNAL_checkResultForToolErrors(result); }, ); }, diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts index 9a9d8cc50f0a..3a9bb6521abd 100644 --- a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts +++ b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from 'vitest'; -import { determineRecordingSettings } from '../../../../src/integrations/tracing/vercelai/instrumentation'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core'; +import { + determineRecordingSettings, + _INTERNAL_checkResultForToolErrors, +} from '../../../../src/integrations/tracing/vercelai/instrumentation'; describe('determineRecordingSettings', () => { test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => { @@ -212,3 +216,66 @@ describe('determineRecordingSettings', () => { }); }); }); + +describe('checkResultForToolErrors', () => { + beforeEach(() => { + _INTERNAL_toolCallSpanContextMap.clear(); + }); + + test('cleans up span context map on successful tool-result', () => { + _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); + _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); + + _INTERNAL_checkResultForToolErrors({ + content: [{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }], + }); + + expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); + // tool-2 should be unaffected + expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' }); + }); + + test('cleans up span context map on tool-error', () => { + _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); + + _INTERNAL_checkResultForToolErrors({ + content: [{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }], + }); + + expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); + }); + + test('handles mixed tool-result and tool-error in same content array', () => { + _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); + _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); + + _INTERNAL_checkResultForToolErrors({ + content: [ + { type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }, + { type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') }, + ], + }); + + expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); + expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined(); + }); + + test('does not throw for tool-error with unknown toolCallId', () => { + _INTERNAL_checkResultForToolErrors({ + content: [{ type: 'tool-error', toolCallId: 'unknown', toolName: 'bash', error: new Error('fail') }], + }); + + // Should not throw, just captures without span linking + }); + + test('ignores results without content array', () => { + _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); + + _INTERNAL_checkResultForToolErrors({}); + _INTERNAL_checkResultForToolErrors(null); + _INTERNAL_checkResultForToolErrors({ content: 'not-an-array' }); + + // Map should be untouched + expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' }); + }); +}); From 3ef0c4a2043d0bfe0bf284f27f98ad4cb7dcdc6e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 16:25:47 +0100 Subject: [PATCH 6/8] renames --- packages/core/src/tracing/vercel-ai/index.ts | 8 +++++++- .../tracing/vercelai/instrumentation.ts | 5 ++--- .../tracing/vercelai/instrumentation.test.ts | 18 +++++++++--------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index c7bc3235d2f4..59ec08434f86 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -19,7 +19,13 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { EMBEDDINGS_OPS, GENERATE_CONTENT_OPS, INVOKE_AGENT_OPS, RERANK_OPS, toolCallSpanContextMap } from './constants'; +import { + EMBEDDINGS_OPS, + GENERATE_CONTENT_OPS, + INVOKE_AGENT_OPS, + RERANK_OPS, + toolCallSpanContextMap, +} from './constants'; import type { TokenSummary } from './types'; import { accumulateTokensForParent, diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 3e569275510a..54e68f975a40 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,6 +1,5 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { Span } from '@sentry/core'; import { _INTERNAL_cleanupToolCallSpanContext, _INTERNAL_getSpanContextForToolCallId, @@ -83,7 +82,7 @@ function isToolResult(obj: unknown): obj is { type: 'tool-result'; toolCallId: s * Check for tool errors in the result and capture them * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content */ -export function _INTERNAL_checkResultForToolErrors(result: unknown): void { +export function checkResultForToolErrors(result: unknown): void { if (typeof result !== 'object' || result === null || !('content' in result)) { return; } @@ -265,7 +264,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }, () => {}, result => { - _INTERNAL_checkResultForToolErrors(result); + checkResultForToolErrors(result); }, ); }, diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts index 3a9bb6521abd..5312c2819766 100644 --- a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts +++ b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, test } from 'vitest'; import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core'; +import { beforeEach, describe, expect, test } from 'vitest'; import { + checkResultForToolErrors, determineRecordingSettings, - _INTERNAL_checkResultForToolErrors, } from '../../../../src/integrations/tracing/vercelai/instrumentation'; describe('determineRecordingSettings', () => { @@ -226,7 +226,7 @@ describe('checkResultForToolErrors', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); - _INTERNAL_checkResultForToolErrors({ + checkResultForToolErrors({ content: [{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }], }); @@ -238,7 +238,7 @@ describe('checkResultForToolErrors', () => { test('cleans up span context map on tool-error', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - _INTERNAL_checkResultForToolErrors({ + checkResultForToolErrors({ content: [{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }], }); @@ -249,7 +249,7 @@ describe('checkResultForToolErrors', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); - _INTERNAL_checkResultForToolErrors({ + checkResultForToolErrors({ content: [ { type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }, { type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') }, @@ -261,7 +261,7 @@ describe('checkResultForToolErrors', () => { }); test('does not throw for tool-error with unknown toolCallId', () => { - _INTERNAL_checkResultForToolErrors({ + checkResultForToolErrors({ content: [{ type: 'tool-error', toolCallId: 'unknown', toolName: 'bash', error: new Error('fail') }], }); @@ -271,9 +271,9 @@ describe('checkResultForToolErrors', () => { test('ignores results without content array', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - _INTERNAL_checkResultForToolErrors({}); - _INTERNAL_checkResultForToolErrors(null); - _INTERNAL_checkResultForToolErrors({ content: 'not-an-array' }); + checkResultForToolErrors({}); + checkResultForToolErrors(null); + checkResultForToolErrors({ content: 'not-an-array' }); // Map should be untouched expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' }); From f69842023918468a2d2a4f8fcf5d0f5d316819bb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 16:55:13 +0100 Subject: [PATCH 7/8] refactor --- .../tracing/vercelai/instrumentation.ts | 113 +++++++++--------- .../tracing/vercelai/instrumentation.test.ts | 44 ++----- 2 files changed, 70 insertions(+), 87 deletions(-) diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 54e68f975a40..4ab5c4292ef1 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -69,20 +69,13 @@ function isToolError(obj: unknown): obj is ToolError { ); } -function isToolResult(obj: unknown): obj is { type: 'tool-result'; toolCallId: string } { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - const candidate = obj as Record; - return candidate.type === 'tool-result' && typeof candidate.toolCallId === 'string'; -} - /** - * Check for tool errors in the result and capture them - * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content + * Process tool call results: capture tool errors and clean up span context mappings. + * + * Error checking runs first (needs span context for linking), then cleanup removes all entries. + * Tool errors are not rejected in Vercel AI V5 — they appear as metadata in the result content. */ -export function checkResultForToolErrors(result: unknown): void { +export function processToolCallResults(result: unknown): void { if (typeof result !== 'object' || result === null || !('content' in result)) { return; } @@ -92,57 +85,65 @@ export function checkResultForToolErrors(result: unknown): void { return; } - for (const item of resultObj.content) { - // Clean up successful tool call entries to prevent memory leaks - if (isToolResult(item)) { - _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); + captureToolErrors(resultObj.content); + cleanupToolCallSpanContexts(resultObj.content); +} + +function captureToolErrors(content: Array): void { + for (const item of content) { + if (!isToolError(item)) { continue; } - if (isToolError(item)) { - // Try to get the span context associated with this tool call ID - const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); - - if (spanContext) { - // We have the span context, so link the error using span and trace IDs - withScope(scope => { - // Set the span and trace context for proper linking - scope.setContext('trace', { - trace_id: spanContext.traceId, - span_id: spanContext.spanId, - }); + // Try to get the span context associated with this tool call ID + const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); + if (spanContext) { + // We have the span context, so link the error using span and trace IDs + withScope(scope => { + scope.setContext('trace', { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + }); - scope.setLevel('error'); + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, - }); + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, }); - - // Clean up the span mapping since we've processed this tool error - // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. - _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); - } else { - // Fallback: capture without span linking - withScope(scope => { - scope.setTag('vercel.ai.tool.name', item.toolName); - scope.setTag('vercel.ai.tool.callId', item.toolCallId); - scope.setLevel('error'); - - captureException(item.error, { - mechanism: { - type: 'auto.vercelai.otel', - handled: false, - }, - }); + }); + } else { + // Fallback: capture without span linking + withScope(scope => { + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, }); - } + }); + } + } +} + +export function cleanupToolCallSpanContexts(content: Array): void { + for (const item of content) { + if ( + typeof item === 'object' && + item !== null && + 'toolCallId' in item && + typeof (item as Record).toolCallId === 'string' + ) { + _INTERNAL_cleanupToolCallSpanContext((item as Record).toolCallId as string); } } } @@ -264,7 +265,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }, () => {}, result => { - checkResultForToolErrors(result); + processToolCallResults(result); }, ); }, diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts index 5312c2819766..08c44c50b68a 100644 --- a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts +++ b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts @@ -1,7 +1,7 @@ import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core'; import { beforeEach, describe, expect, test } from 'vitest'; import { - checkResultForToolErrors, + cleanupToolCallSpanContexts, determineRecordingSettings, } from '../../../../src/integrations/tracing/vercelai/instrumentation'; @@ -217,65 +217,47 @@ describe('determineRecordingSettings', () => { }); }); -describe('checkResultForToolErrors', () => { +describe('cleanupToolCallSpanContexts', () => { beforeEach(() => { _INTERNAL_toolCallSpanContextMap.clear(); }); - test('cleans up span context map on successful tool-result', () => { + test('cleans up span context for tool-result items', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); - checkResultForToolErrors({ - content: [{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }], - }); + cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]); expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); - // tool-2 should be unaffected expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' }); }); - test('cleans up span context map on tool-error', () => { + test('cleans up span context for tool-error items', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - checkResultForToolErrors({ - content: [{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }], - }); + cleanupToolCallSpanContexts([{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }]); expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); }); - test('handles mixed tool-result and tool-error in same content array', () => { + test('cleans up mixed tool-result and tool-error in same content array', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); _INTERNAL_toolCallSpanContextMap.set('tool-2', { traceId: 't2', spanId: 's2' }); - checkResultForToolErrors({ - content: [ - { type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }, - { type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') }, - ], - }); + cleanupToolCallSpanContexts([ + { type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }, + { type: 'tool-error', toolCallId: 'tool-2', toolName: 'bash', error: new Error('fail') }, + ]); expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toBeUndefined(); }); - test('does not throw for tool-error with unknown toolCallId', () => { - checkResultForToolErrors({ - content: [{ type: 'tool-error', toolCallId: 'unknown', toolName: 'bash', error: new Error('fail') }], - }); - - // Should not throw, just captures without span linking - }); - - test('ignores results without content array', () => { + test('ignores items without toolCallId', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - checkResultForToolErrors({}); - checkResultForToolErrors(null); - checkResultForToolErrors({ content: 'not-an-array' }); + cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]); - // Map should be untouched expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' }); }); }); From 2d814efb6b4ba1cd4e118cc01a198dc720bbc48e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 26 Feb 2026 18:06:12 +0100 Subject: [PATCH 8/8] yarn fix --- packages/core/src/tracing/vercel-ai/index.ts | 1 + .../node/src/integrations/tracing/vercelai/instrumentation.ts | 3 +++ .../integrations/tracing/vercelai/instrumentation.test.ts | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index 59ec08434f86..7b0dad02e351 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 4ab5c4292ef1..2dfa8657bd4c 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -135,6 +135,9 @@ function captureToolErrors(content: Array): void { } } +/** + * Remove span context entries for all completed tool calls in the content array. + */ export function cleanupToolCallSpanContexts(content: Array): void { for (const item of content) { if ( diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts index 08c44c50b68a..c63efb8e2d0a 100644 --- a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts +++ b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts @@ -235,7 +235,9 @@ describe('cleanupToolCallSpanContexts', () => { test('cleans up span context for tool-error items', () => { _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - cleanupToolCallSpanContexts([{ type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }]); + cleanupToolCallSpanContexts([ + { type: 'tool-error', toolCallId: 'tool-1', toolName: 'bash', error: new Error('fail') }, + ]); expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); });