From 835edeb2de9b9d53fd6c240c76f4e639ff9ab971 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 24 Jul 2025 11:13:22 +0200 Subject: [PATCH] fix(node): Ensure tool errors for `vercelAiIntegration` have correct trace connected (#17132) This fixes the problem that for tool errors triggered from vercel-ai integration, traces were not connected. This seemed to happen because errors bubble up to the global unhandled rejection handler, where the span is no longer active and thus the trace cannot be connected. This PR fixes this by attaching the active span to the error and using this in the unhandled rejection handler, if it exists. Closes https://github.com/getsentry/sentry-javascript/issues/17108 --- .../aws-lambda-layer-cjs/package.json | 3 +- .../handle-error-tracesSampleRate-0/server.ts | 2 +- .../handle-error-tracesSampleRate-0/test.ts | 1 - .../suites/express/handle-error/server.ts | 21 ++ .../suites/express/handle-error/test.ts | 44 ++++ .../scenario-with-span-ended.ts | 46 ++++ .../scenario-with-span.ts | 14 + .../onUnhandledRejectionIntegration/test.ts | 55 ++++ .../tracing/vercelai/instrument-with-pii.mjs | 2 +- .../suites/tracing/vercelai/instrument.mjs | 2 +- .../scenario-error-in-tool-express.mjs | 50 ++++ .../vercelai/scenario-error-in-tool.mjs | 40 +++ .../suites/tracing/vercelai/test.ts | 245 +++++++++++++++++- .../src/integrations/onunhandledrejection.ts | 35 ++- .../tracing/vercelai/instrumentation.ts | 25 +- 15 files changed, 564 insertions(+), 21 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/express/handle-error/server.ts create mode 100644 dev-packages/node-integration-tests/suites/express/handle-error/test.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json index 1d98acc92859..25489cf0a35e 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json @@ -1,7 +1,8 @@ { - "name": "node-express-app", + "name": "aws-lambda-layer-cjs", "version": "1.0.0", "private": true, + "type": "commonjs", "scripts": { "start": "node src/run.js", "test": "playwright test", diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts index 323093ce38e0..329d658d905a 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts @@ -5,7 +5,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', transport: loggingTransport, - tracesSampleRate: 1, + tracesSampleRate: 0, }); import express from 'express'; diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts index b6bc5de97cdb..1f434ebc0971 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts @@ -7,7 +7,6 @@ afterAll(() => { test('should capture and send Express controller error with txn name if tracesSampleRate is 0', async () => { const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') .expect({ event: { exception: { diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error/server.ts new file mode 100644 index 000000000000..ba8fb32cc108 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/handle-error/server.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1, + transport: loggingTransport, +}); + +import express from 'express'; + +const app = express(); + +app.get('/test/express/:id', req => { + throw new Error(`test_error with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts new file mode 100644 index 000000000000..0db624160959 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts @@ -0,0 +1,44 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture and send Express controller error with txn name if tracesSampleRate is 1', async () => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + transaction: 'GET /test/express/:id', + }, + }) + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'test_error with id 123', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + transaction: 'GET /test/express/:id', + }, + }) + .start(); + runner.makeRequest('get', '/test/express/123', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts new file mode 100644 index 000000000000..72d83d70ec72 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +recordSpan(async () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSomething(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSomethingWithError(); +}); + +async function doSomething(): Promise { + return Promise.resolve(); +} + +async function doSomethingWithError(): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + throw new Error('test error'); +} + +// Duplicating some code from vercel-ai to verify how things work in more complex/weird scenarios +function recordSpan(fn: (span: unknown) => Promise): Promise { + return Sentry.startSpanManual({ name: 'test-span' }, async span => { + try { + const result = await fn(span); + span.end(); + return result; + } catch (error) { + try { + span.setStatus({ code: 2 }); + } finally { + // always stop the span when there is an error: + span.end(); + } + + throw error; + } + }); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts new file mode 100644 index 000000000000..edff30f114ca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test-span' }, async () => { + throw new Error('test error'); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index 2f4a22c835a4..468e66a058ca 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -1,3 +1,4 @@ +import type { Event } from '@sentry/node'; import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; @@ -123,4 +124,58 @@ test rejection`); .start() .completed(); }); + + test('handles unhandled rejection in spans', async () => { + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner(__dirname, 'scenario-with-span.ts') + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent!.transaction).toBe('test-span'); + + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); + }); + + test('handles unhandled rejection in spans that are ended early', async () => { + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner(__dirname, 'scenario-with-span-ended.ts') + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent!.transaction).toBe('test-span'); + + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs index d69f7dca5feb..b798e21228f5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs @@ -7,5 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration({ force: true })], + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs index e4cd7b9cabd7..5e898ee1949d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs @@ -6,5 +6,5 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration({ force: true })], + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs new file mode 100644 index 000000000000..82bfe3c35445 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import express from 'express'; +import { z } from 'zod'; + +const app = express(); + +app.get('/test/error-in-tool', async (_req, res, next) => { + Sentry.setTag('test-tag', 'test-value'); + + try { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch (error) { + next(error); + return; + } + + res.send({ message: 'OK' }); +}); +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..4185d972da4d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + Sentry.setTag('test-tag', 'test-value'); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + }); +} + +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..5353f53f42e3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -1,7 +1,7 @@ +import type { Event } from '@sentry/node'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -// `ai` SDK only support Node 18+ describe('Vercel AI integration', () => { afterAll(() => { cleanupChildProcesses(); @@ -416,4 +416,247 @@ describe('Vercel AI integration', () => { await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error in tool', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + ]), + + tags: { + 'test-tag': 'test-value', + }, + }; + + let traceId: string = 'unset-trace-id'; + let spanId: string = 'unset-span-id'; + + const expectedError = { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + }, + tags: { + 'test-tag': 'test-value', + }, + }; + + await createRunner() + .expect({ + transaction: transaction => { + expect(transaction).toMatchObject(expectedTransaction); + traceId = transaction.contexts!.trace!.trace_id; + spanId = transaction.contexts!.trace!.span_id; + }, + }) + .expect({ + event: event => { + expect(event).toMatchObject(expectedError); + expect(event.contexts!.trace!.trace_id).toBe(traceId); + expect(event.contexts!.trace!.span_id).toBe(spanId); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool-express.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error in tool in express server', async () => { + const expectedTransaction = { + transaction: 'GET /test/error-in-tool', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + ]), + + tags: { + 'test-tag': 'test-value', + }, + }; + + const expectedError = { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + }, + tags: { + 'test-tag': 'test-value', + }, + }; + + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + const runner = await createRunner() + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start(); + + await runner.makeRequest('get', '/test/error-in-tool', { expectError: true }); + await runner.completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent).toMatchObject(expectedTransaction); + + expect(errorEvent).toMatchObject(expectedError); + expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); + }); + }); }); diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index 8b41da189a0f..a11d5c3cf7b0 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -1,5 +1,5 @@ -import type { Client, IntegrationFn, SeverityLevel } from '@sentry/core'; -import { captureException, consoleSandbox, defineIntegration, getClient } from '@sentry/core'; +import type { Client, IntegrationFn, SeverityLevel, Span } from '@sentry/core'; +import { captureException, consoleSandbox, defineIntegration, getClient, withActiveSpan } from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; @@ -51,16 +51,27 @@ export function makeUnhandledPromiseHandler( const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; - captureException(reason, { - originalException: promise, - captureContext: { - extra: { unhandledPromiseRejection: true }, - level, - }, - mechanism: { - handled: false, - type: 'onunhandledrejection', - }, + // this can be set in places where we cannot reliably get access to the active span/error + // when the error bubbles up to this handler, we can use this to set the active span + const activeSpanForError = + reason && typeof reason === 'object' ? (reason as { _sentry_active_span?: Span })._sentry_active_span : undefined; + + const activeSpanWrapper = activeSpanForError + ? (fn: () => void) => withActiveSpan(activeSpanForError, fn) + : (fn: () => void) => fn(); + + activeSpanWrapper(() => { + captureException(reason, { + originalException: promise, + captureContext: { + extra: { unhandledPromiseRejection: true }, + level, + }, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }); }); handleRejection(reason, options.mode); diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 4b823670793a..22ec18a682f0 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,6 +1,12 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { getCurrentScope, SDK_VERSION } from '@sentry/core'; +import { + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + handleCallbackErrors, + SDK_VERSION, +} from '@sentry/core'; import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; @@ -132,8 +138,21 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { recordOutputs, }; - // @ts-expect-error we know that the method exists - return originalMethod.apply(this, args); + return handleCallbackErrors( + () => { + // @ts-expect-error we know that the method exists + return originalMethod.apply(this, 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()); + } + }, + ); }; }