diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error.mjs new file mode 100644 index 000000000000..b09421cb09a6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error.mjs @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + // Create a manual outer span (simulating the root span) + await Sentry.startSpan({ op: 'outer', name: 'outer span', description: 'outer span' }, async () => { + // It is expected that the error will bubble up naturally to the outer span + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'calculateTool', + args: '{ "a": 1, "b": 2 }', + }, + ], + }), + }), + experimental_telemetry: { + functionId: 'Simple Agent', + recordInputs: true, + recordOutputs: true, + isEnabled: true, + }, + tools: { + calculateTool: tool({ + description: 'Calculate the result of a math problem. Returns a number.', + parameters: z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }), + type: 'function', + execute: async () => { + throw new Error('Not implemented'); + }, + }), + }, + maxSteps: 2, + system: 'You help users with their math problems.', + prompt: 'What is 1 + 1?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-express-error.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-express-error.mjs new file mode 100644 index 000000000000..896ceceba6f4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-express-error.mjs @@ -0,0 +1,81 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import express from 'express'; +import { createServer } from 'http'; +import { z } from 'zod'; + +async function run() { + const app = express(); + + app.get('/api/chat', async (req, res) => { + try { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Processing your request...', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'calculateTool', + args: '{ "a": 1, "b": 2 }', + }, + ], + }), + }), + experimental_telemetry: { + functionId: 'Chat Assistant', + recordInputs: true, + recordOutputs: true, + isEnabled: true, + }, + tools: { + calculateTool: tool({ + description: 'Calculate the result of a math problem. Returns a number.', + parameters: z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }), + type: 'function', + execute: async () => { + throw new Error('Calculation service unavailable'); + }, + }), + }, + maxSteps: 2, + system: 'You are a helpful chat assistant.', + prompt: 'What is 1 + 1?', + }); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + Sentry.setupExpressErrorHandler(app); + + const server = createServer(app); + + // Start server and make request + server.listen(0, () => { + const port = server.address()?.port; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ port })); + + // Make the request that will trigger the error + fetch(`http://localhost:${port}/api/chat`) + .then(() => { + server.close(); + }) + .catch(() => { + server.close(); + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index f9b853aa4946..f58cf63cdd58 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -416,4 +416,94 @@ describe('Vercel AI integration', () => { await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-error.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('Vercel AI errors should inherit parent trace context from manually created outer span', async () => { + let capturedTransaction: any; + let capturedEvent: any; + + const runner = createRunner() + .expect({ + transaction: (transaction: any) => { + capturedTransaction = transaction; + expect(transaction.transaction).toBe('outer span'); + }, + }) + .expect({ + event: (event: any) => { + capturedEvent = event; + + expect(event).toMatchObject({ + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool calculateTool: Not implemented', + }), + ]), + }, + }); + }, + }) + .start(); + + await runner.completed(); + + const transactionTraceId = capturedTransaction?.contexts?.trace?.trace_id; + const errorTraceId = capturedEvent?.contexts?.trace?.trace_id; + + expect(transactionTraceId).toBeDefined(); + expect(errorTraceId).toBeDefined(); + expect(transactionTraceId).toMatch(/^[a-f0-9]{32}$/); + expect(errorTraceId).toMatch(/^[a-f0-9]{32}$/); + + expect(errorTraceId).toBe(transactionTraceId); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-express-error.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('Vercel AI errors should inherit parent trace context from server HTTP request', async () => { + let capturedTransaction: any; + let capturedEvent: any; + + const runner = createRunner() + .withMockSentryServer() + .expect({ + transaction: (transaction: any) => { + capturedTransaction = transaction; + // Express creates a transaction like "GET /api/chat" + expect(transaction.transaction).toBe('GET /api/chat'); + }, + }) + .expect({ + event: (event: any) => { + capturedEvent = event; + + expect(event).toMatchObject({ + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool calculateTool: Calculation service unavailable', + }), + ]), + }, + }); + }, + }) + .start(); + + await runner.completed(); + + const transactionTraceId = capturedTransaction?.contexts?.trace?.trace_id; + const errorTraceId = capturedEvent?.contexts?.trace?.trace_id; + + expect(transactionTraceId).toBeDefined(); + expect(errorTraceId).toBeDefined(); + expect(transactionTraceId).toMatch(/^[a-f0-9]{32}$/); + expect(errorTraceId).toMatch(/^[a-f0-9]{32}$/); + + expect(errorTraceId).toBe(transactionTraceId); + }); + }); });