diff --git a/packages/node/src/integrations/tracing/vercelai/constants.ts b/packages/node/src/integrations/tracing/vercelai/constants.ts deleted file mode 100644 index ff19038a8cb6..000000000000 --- a/packages/node/src/integrations/tracing/vercelai/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const INTEGRATION_NAME = 'VercelAI' as const; diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts deleted file mode 100644 index b1a923fec8cf..000000000000 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { generateInstrumentOnce } from '../../../otel/instrument'; -import { INTEGRATION_NAME } from './constants'; -import { SentryVercelAiInstrumentation } from './instrumentation'; - -export const instrumentVercelAi = generateInstrumentOnce(INTEGRATION_NAME, () => new SentryVercelAiInstrumentation({})); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts deleted file mode 100644 index 7b3deb2b820f..000000000000 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ /dev/null @@ -1,306 +0,0 @@ -import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; -import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { - _INTERNAL_cleanupToolCallSpanContext, - _INTERNAL_getSpanContextForToolCallId, - addNonEnumerableProperty, - captureException, - getActiveSpan, - getClient, - handleCallbackErrors, - SDK_VERSION, - withScope, -} from '@sentry/core'; -import { INTEGRATION_NAME } from './constants'; -import type { TelemetrySettings, VercelAiIntegration } from './types'; - -const SUPPORTED_VERSIONS = ['>=3.0.0 <7']; - -// List of patched methods -// From: https://sdk.vercel.ai/docs/ai-sdk-core/telemetry#collected-data -const INSTRUMENTED_METHODS = [ - 'generateText', - 'streamText', - 'generateObject', - 'streamObject', - 'embed', - 'embedMany', - 'rerank', -] as const; - -interface MethodFirstArg extends Record { - experimental_telemetry?: TelemetrySettings; -} - -type MethodArgs = [MethodFirstArg, ...unknown[]]; - -type PatchedModuleExports = Record<(typeof INSTRUMENTED_METHODS)[number], (...args: MethodArgs) => unknown> & - Record; - -interface RecordingOptions { - recordInputs?: boolean; - recordOutputs?: boolean; -} - -interface ToolError { - type: 'tool-error' | 'tool-result' | 'tool-call'; - toolCallId: string; - toolName: string; - input?: { - [key: string]: unknown; - }; - error: Error; - dynamic?: boolean; -} - -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' && - candidate.error instanceof Error - ); -} - -/** - * 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 processToolCallResults(result: unknown): void { - if (typeof result !== 'object' || result === null || !('content' in result)) { - return; - } - - const resultObj = result as { content: Array }; - if (!Array.isArray(resultObj.content)) { - return; - } - - captureToolErrors(resultObj.content); - cleanupToolCallSpanContexts(resultObj.content); -} - -function captureToolErrors(content: Array): void { - for (const item of content) { - if (!isToolError(item)) { - continue; - } - - // 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 => { - 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, - }, - }); - }); - } 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, - }, - }); - }); - } - } -} - -/** - * 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 ( - typeof item === 'object' && - item !== null && - 'toolCallId' in item && - typeof (item as Record).toolCallId === 'string' - ) { - _INTERNAL_cleanupToolCallSpanContext((item as Record).toolCallId as string); - } - } -} - -/** - * Determines whether to record inputs and outputs for Vercel AI telemetry based on the configuration hierarchy. - * - * The order of precedence is: - * 1. The vercel ai integration options - * 2. The experimental_telemetry options in the vercel ai method calls - * 3. When telemetry is explicitly enabled (isEnabled: true), default to recording - * 4. Otherwise, use the dataCollection.genAI settings from client options - */ -export function determineRecordingSettings( - integrationRecordingOptions: RecordingOptions | undefined, - methodTelemetryOptions: RecordingOptions, - telemetryExplicitlyEnabled: boolean | undefined, - defaultInputsEnabled: boolean, - defaultOutputsEnabled: boolean, -): { recordInputs: boolean; recordOutputs: boolean } { - const recordInputs = - integrationRecordingOptions?.recordInputs !== undefined - ? integrationRecordingOptions.recordInputs - : methodTelemetryOptions.recordInputs !== undefined - ? methodTelemetryOptions.recordInputs - : telemetryExplicitlyEnabled === true - ? true // When telemetry is explicitly enabled, default to recording inputs - : defaultInputsEnabled; - - const recordOutputs = - integrationRecordingOptions?.recordOutputs !== undefined - ? integrationRecordingOptions.recordOutputs - : methodTelemetryOptions.recordOutputs !== undefined - ? methodTelemetryOptions.recordOutputs - : telemetryExplicitlyEnabled === true - ? true // When telemetry is explicitly enabled, default to recording outputs - : defaultOutputsEnabled; - - return { recordInputs, recordOutputs }; -} - -/** - * This detects is added by the Sentry Vercel AI Integration to detect if the integration should - * be enabled. - * - * It also patches the `ai` module to enable Vercel AI telemetry automatically for all methods. - */ -export class SentryVercelAiInstrumentation extends InstrumentationBase { - private _isPatched = false; - private _callbacks: (() => void)[] = []; - - public constructor(config: InstrumentationConfig = {}) { - super('@sentry/instrumentation-vercel-ai', SDK_VERSION, config); - } - - /** - * Initializes the instrumentation by defining the modules to be patched. - */ - public init(): InstrumentationModuleDefinition { - const module = new InstrumentationNodeModuleDefinition('ai', SUPPORTED_VERSIONS, this._patch.bind(this)); - return module; - } - - /** - * Call the provided callback when the module is patched. - * If it has already been patched, the callback will be called immediately. - */ - public callWhenPatched(callback: () => void): void { - if (this._isPatched) { - callback(); - } else { - this._callbacks.push(callback); - } - } - - /** - * Patches module exports to enable Vercel AI telemetry. - */ - private _patch(moduleExports: PatchedModuleExports): unknown { - this._isPatched = true; - - this._callbacks.forEach(callback => callback()); - this._callbacks = []; - - const generatePatch = unknown>(originalMethod: T): T => { - return new Proxy(originalMethod, { - apply: (target, thisArg, args: MethodArgs) => { - const existingExperimentalTelemetry = args[0].experimental_telemetry || {}; - const isEnabled = existingExperimentalTelemetry.isEnabled; - - const client = getClient(); - const integration = client?.getIntegrationByName(INTEGRATION_NAME); - const integrationOptions = integration?.options; - const genAI = integration ? client?.getDataCollectionOptions().genAI : undefined; - - const { recordInputs, recordOutputs } = determineRecordingSettings( - integrationOptions, - existingExperimentalTelemetry, - isEnabled, - Boolean(genAI?.inputs), - Boolean(genAI?.outputs), - ); - - args[0].experimental_telemetry = { - ...existingExperimentalTelemetry, - isEnabled: isEnabled !== undefined ? isEnabled : true, - recordInputs, - recordOutputs, - }; - - return handleCallbackErrors( - () => Reflect.apply(target, thisArg, args), - error => { - // This error bubbles up to unhandledrejection handler (if not handled before), - // where we do not know the active span anymore - // So to circumvent this, we set the active span on the error object - // which is picked up by the unhandledrejection handler - if (error && typeof error === 'object') { - addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan()); - } - }, - () => {}, - result => { - processToolCallResults(result); - }, - ); - }, - }); - }; - - // Is this an ESM module? - // https://tc39.es/ecma262/#sec-module-namespace-objects - if (Object.prototype.toString.call(moduleExports) === '[object Module]') { - // In ESM we take the usual route and just replace the exports we want to instrument - for (const method of INSTRUMENTED_METHODS) { - // Skip methods that don't exist in this version of the AI SDK (e.g., rerank was added in v6) - if (moduleExports[method] != null) { - moduleExports[method] = generatePatch(moduleExports[method]); - } - } - - return moduleExports; - } else { - // In CJS we can't replace the exports in the original module because they - // don't have setters, so we create a new object with the same properties - const patchedModuleExports = INSTRUMENTED_METHODS.reduce((acc, curr) => { - // Skip methods that don't exist in this version of the AI SDK (e.g., rerank was added in v6) - if (moduleExports[curr] != null) { - acc[curr] = generatePatch(moduleExports[curr]); - } - return acc; - }, {} as PatchedModuleExports); - - return { ...moduleExports, ...patchedModuleExports }; - } - } -} diff --git a/packages/node/src/integrations/tracing/vercelai/types.ts b/packages/node/src/integrations/tracing/vercelai/types.ts deleted file mode 100644 index cdf8a515867a..000000000000 --- a/packages/node/src/integrations/tracing/vercelai/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Integration } from '@sentry/core'; -import type { VercelAiOptions as VercelAiBaseOptions } from '@sentry/server-utils'; - -/** - * Telemetry configuration. - */ -export type TelemetrySettings = { - /** - * Enable or disable telemetry. Disabled by default while experimental. - */ - isEnabled?: boolean; - /** - * Enable or disable input recording. Enabled by default. - * - * You might want to disable input recording to avoid recording sensitive - * information, to reduce data transfers, or to increase performance. - */ - recordInputs?: boolean; - /** - * Enable or disable output recording. Enabled by default. - * - * You might want to disable output recording to avoid recording sensitive - * information, to reduce data transfers, or to increase performance. - */ - recordOutputs?: boolean; - /** - * Identifier for this function. Used to group telemetry data by function. - */ - functionId?: string; - /** - * Additional information to include in the telemetry data. - */ - metadata?: Record; -}; - -/** - * Attribute values may be any non-nullish primitive value except an object. - * - * null or undefined attribute values are invalid and will result in undefined behavior. - */ -export declare type AttributeValue = - | string - | number - | boolean - | Array - | Array - | Array; - -export interface VercelAiOptions extends VercelAiBaseOptions { - /** - * By default, the instrumentation will register span processors only when the ai package is used. - * If you want to register the span processors even when the ai package usage cannot be detected, you can set `force` to `true`. - */ - force?: boolean; -} - -export interface VercelAiIntegration extends Integration { - options: VercelAiOptions; -} diff --git a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts b/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts deleted file mode 100644 index f27098cfe138..000000000000 --- a/packages/node/test/integrations/tracing/vercelai/instrumentation.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { _INTERNAL_getSpanContextForToolCallId, _INTERNAL_toolCallSpanContextMap } from '@sentry/core'; -import { beforeEach, describe, expect, test } from 'vitest'; -import { - cleanupToolCallSpanContexts, - determineRecordingSettings, -} from '../../../../src/integrations/tracing/vercelai/instrumentation'; - -describe('determineRecordingSettings', () => { - test('should use integration recording options when provided (recordInputs: true, recordOutputs: false)', () => { - const result = determineRecordingSettings( - { recordInputs: true, recordOutputs: false }, // integrationRecordingOptions - {}, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: false, - }); - }); - - test('should use integration recording options when provided (recordInputs: false, recordOutputs: true)', () => { - const result = determineRecordingSettings( - { recordInputs: false, recordOutputs: true }, // integrationRecordingOptions - {}, // methodTelemetryOptions - true, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled - true, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, - recordOutputs: true, - }); - }); - - test('should fall back to method telemetry options when integration options not provided', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - { recordInputs: true, recordOutputs: false }, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: false, - }); - }); - - test('should prefer integration recording options over method telemetry options', () => { - const result = determineRecordingSettings( - { recordInputs: false, recordOutputs: false }, // integrationRecordingOptions - { recordInputs: true, recordOutputs: true }, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled - true, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, - recordOutputs: false, - }); - }); - - test('should default to recording when telemetry is explicitly enabled', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - true, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: true, - }); - }); - - test('should use default recording setting when telemetry is explicitly disabled', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled - true, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: true, - }); - }); - - test('should use default recording setting when telemetry enablement is undefined', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled - true, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: true, - }); - }); - - test('should not record when default recording is disabled and no explicit configuration', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, - recordOutputs: false, - }); - }); - - test('should handle partial integration recording options (only recordInputs)', () => { - const result = determineRecordingSettings( - { recordInputs: true }, // integrationRecordingOptions - {}, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: false, // falls back to defaultOutputsEnabled - }); - }); - - test('should handle partial integration recording options (only recordOutputs)', () => { - const result = determineRecordingSettings( - { recordOutputs: true }, // integrationRecordingOptions - {}, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, // falls back to defaultInputsEnabled - recordOutputs: true, - }); - }); - - test('should handle partial method telemetry options (only recordInputs)', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - { recordInputs: true }, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: false, // falls back to defaultOutputsEnabled - }); - }); - - test('should handle partial method telemetry options (only recordOutputs)', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - { recordOutputs: true }, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled - false, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, // falls back to defaultInputsEnabled - recordOutputs: true, - }); - }); - - test('should prefer integration recording options over method telemetry for partial configs', () => { - const result = determineRecordingSettings( - { recordInputs: false }, // integrationRecordingOptions - { recordInputs: true, recordOutputs: true }, // methodTelemetryOptions - false, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled - true, // defaultOutputsEnabled - ); - - expect(result).toEqual({ - recordInputs: false, // from integration recording options - recordOutputs: true, // from method telemetry options - }); - }); - - test('complex scenario: dataCollection.genAI enabled, telemetry enablement undefined, mixed options', () => { - const result = determineRecordingSettings( - { recordOutputs: false }, // integrationRecordingOptions - { recordInputs: false }, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled (dataCollection.genAI.inputs: true) - true, // defaultOutputsEnabled (dataCollection.genAI.outputs: true) - ); - - expect(result).toEqual({ - recordInputs: false, // from method telemetry options - recordOutputs: false, // from integration recording options - }); - }); - - test('complex scenario: explicit telemetry enabled overrides dataCollection.genAI disabled', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - true, // telemetryExplicitlyEnabled - false, // defaultInputsEnabled (dataCollection.genAI.inputs: false) - false, // defaultOutputsEnabled (dataCollection.genAI.outputs: false) - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: true, - }); - }); - - test('supports asymmetric defaults: inputs enabled, outputs disabled', () => { - const result = determineRecordingSettings( - {}, // integrationRecordingOptions - {}, // methodTelemetryOptions - undefined, // telemetryExplicitlyEnabled - true, // defaultInputsEnabled (dataCollection.genAI.inputs: true) - false, // defaultOutputsEnabled (dataCollection.genAI.outputs: false) - ); - - expect(result).toEqual({ - recordInputs: true, - recordOutputs: false, - }); - }); -}); - -describe('cleanupToolCallSpanContexts', () => { - beforeEach(() => { - _INTERNAL_toolCallSpanContextMap.clear(); - }); - - 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' }); - - cleanupToolCallSpanContexts([{ type: 'tool-result', toolCallId: 'tool-1', toolName: 'bash' }]); - - expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); - expect(_INTERNAL_getSpanContextForToolCallId('tool-2')).toEqual({ traceId: 't2', spanId: 's2' }); - }); - - 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') }, - ]); - - expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toBeUndefined(); - }); - - 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' }); - - 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('ignores items without toolCallId', () => { - _INTERNAL_toolCallSpanContextMap.set('tool-1', { traceId: 't1', spanId: 's1' }); - - cleanupToolCallSpanContexts([{ type: 'text', text: 'hello' } as unknown as object]); - - expect(_INTERNAL_getSpanContextForToolCallId('tool-1')).toEqual({ traceId: 't1', spanId: 's1' }); - }); -});