From 5b841198614d5cea9051045568175974fa4c8bd3 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 14:58:35 +0200 Subject: [PATCH 1/8] fix(vercel-ai): Ensure tool errors have correct trace connected --- .../tracing/vercelai/instrument-with-pii.mjs | 2 +- .../suites/tracing/vercelai/instrument.mjs | 2 +- .../vercelai/scenario-error-in-tool.mjs | 38 ++++++ .../suites/tracing/vercelai/test.ts | 112 +++++++++++++++++- .../src/integrations/onunhandledrejection.ts | 34 ++++-- .../tracing/vercelai/instrumentation.ts | 25 +++- 6 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs 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.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..0305db20d606 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + 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 args => { + 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..42e2c1edeacd 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,6 @@ 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 +415,115 @@ 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', + }), + ]), + }; + + 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', + }), + ]), + }, + }; + + 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(); + }); + }); }); diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index 8b41da189a0f..df08117ea538 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,26 @@ 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 as { _sentry_active_span?: Span })._sentry_active_span; + + 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()); + } + }, + ); }; } From 51397fa5d67c28032a603a7a381964d4f3620f60 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 15:19:58 +0200 Subject: [PATCH 2/8] fix linting --- .../suites/tracing/vercelai/scenario-error-in-tool.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 0305db20d606..b70448edd371 100644 --- 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 @@ -25,7 +25,7 @@ async function run() { tools: { getWeather: { parameters: z.object({ location: z.string() }), - execute: async args => { + execute: async () => { throw new Error('Error in tool'); }, }, From 63e07ceec3c2c2f38ca964dbc4821858af528d10 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 15:49:41 +0200 Subject: [PATCH 3/8] more tests & fixes --- .../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-error-in-tool-express.mjs | 50 +++++++ .../vercelai/scenario-error-in-tool.mjs | 2 + .../suites/tracing/vercelai/test.ts | 133 ++++++++++++++++++ 7 files changed, 251 insertions(+), 2 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/tracing/vercelai/scenario-error-in-tool-express.mjs 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..2bc1662fe6cc 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/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..29879a42fdb3 --- /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 { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +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 index b70448edd371..4185d972da4d 100644 --- 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 @@ -4,6 +4,8 @@ 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({ 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 42e2c1edeacd..5ed4a42e1cbc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { Event } from '@sentry/node'; describe('Vercel AI integration', () => { afterAll(() => { @@ -485,6 +486,10 @@ describe('Vercel AI integration', () => { status: 'unknown_error', }), ]), + + tags: { + 'test-tag': 'test-value', + }, }; let traceId: string = 'unset-trace-id'; @@ -505,6 +510,9 @@ describe('Vercel AI integration', () => { }), ]), }, + tags: { + 'test-tag': 'test-value', + }, }; await createRunner() @@ -526,4 +534,129 @@ describe('Vercel AI integration', () => { .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); + }); + }); }); From d97451ccec5e980e1946655aea2053b66bdbe640 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 15:52:16 +0200 Subject: [PATCH 4/8] fixes --- .../suites/express/handle-error-tracesSampleRate-0/server.ts | 2 +- .../tracing/vercelai/scenario-error-in-tool-express.mjs | 4 ++-- .../node-integration-tests/suites/tracing/vercelai/test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 2bc1662fe6cc..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: 0 + tracesSampleRate: 0, }); import express from 'express'; 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 index 29879a42fdb3..82bfe3c35445 100644 --- 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 @@ -1,9 +1,9 @@ import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; import { generateText } from 'ai'; import { MockLanguageModelV1 } from 'ai/test'; -import { z } from 'zod'; -import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; import express from 'express'; +import { z } from 'zod'; const app = express(); 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 5ed4a42e1cbc..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,6 +1,6 @@ +import type { Event } from '@sentry/node'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -import { Event } from '@sentry/node'; describe('Vercel AI integration', () => { afterAll(() => { From 8100e996b5b88751047ce9ff95daf86803bf49a9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 16:08:52 +0200 Subject: [PATCH 5/8] guard reason --- packages/node-core/src/integrations/onunhandledrejection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index df08117ea538..a11d5c3cf7b0 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -53,7 +53,8 @@ export function makeUnhandledPromiseHandler( // 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 as { _sentry_active_span?: Span })._sentry_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) From c6caf49131266864d1a602aaab9b2e38110883e7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 23 Jul 2025 16:58:49 +0200 Subject: [PATCH 6/8] add simple tests --- .../scenario-with-span-ended.ts | 42 ++++++++++++++ .../scenario-with-span.ts | 13 +++++ .../onUnhandledRejectionIntegration/test.ts | 55 +++++++++++++++++++ 3 files changed, 110 insertions(+) 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 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..2b23502e1b54 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts @@ -0,0 +1,42 @@ +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, +}); + +recordSpan(async () => { + doSomething(); + doSomethingWithError(); +}); + +async function doSomething() { + return Promise.resolve(); +} + +async function doSomethingWithError() { + await new Promise(resolve => setTimeout(resolve, 100)); + throw new Error('test error'); +} + +function recordSpan(fn: (span: unknown) => 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..c057b7b2efd7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts @@ -0,0 +1,13 @@ +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, +}); + +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..fefe34c8b674 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 @@ -2,6 +2,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import type { Event } from '@sentry/node'; describe('onUnhandledRejectionIntegration', () => { afterAll(() => { @@ -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); + }); }); From 1f2443f2af1d3131e25802521a071a763c3a519c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 24 Jul 2025 09:35:31 +0200 Subject: [PATCH 7/8] fix linting --- .../scenario-with-span-ended.ts | 10 +++++++--- .../scenario-with-span.ts | 1 + .../public-api/onUnhandledRejectionIntegration/test.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) 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 index 2b23502e1b54..72d83d70ec72 100644 --- 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 @@ -8,21 +8,25 @@ Sentry.init({ 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() { +async function doSomething(): Promise { return Promise.resolve(); } -async function doSomethingWithError() { +async function doSomethingWithError(): Promise { await new Promise(resolve => setTimeout(resolve, 100)); throw new Error('test error'); } -function recordSpan(fn: (span: unknown) => Promise) { +// 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); 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 index c057b7b2efd7..edff30f114ca 100644 --- 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 @@ -8,6 +8,7 @@ Sentry.init({ 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 fefe34c8b674..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,8 +1,8 @@ +import type { Event } from '@sentry/node'; import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -import type { Event } from '@sentry/node'; describe('onUnhandledRejectionIntegration', () => { afterAll(() => { From 59e546e4b4f5ec7df770bc0d5c26f9f55679b97c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 24 Jul 2025 10:02:42 +0200 Subject: [PATCH 8/8] debug aws-layer-test ?? --- .../test-applications/aws-lambda-layer-cjs/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 3c169365af60..377b8800f9dd 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",