diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js new file mode 100644 index 000000000000..7ff548624e5f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js @@ -0,0 +1,27 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.onUnhandledRejectionIntegration({ + // Use default mode: 'warn' - integration is active but should ignore CustomIgnoredError + ignore: [{ name: 'CustomIgnoredError' }], + }), + ], +}); + +// Create a custom error that should be ignored +class CustomIgnoredError extends Error { + constructor(message) { + super(message); + this.name = 'CustomIgnoredError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by the custom ignore matcher and not produce a warning +Promise.reject(new CustomIgnoredError('This error should be ignored')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js new file mode 100644 index 000000000000..623aa8eaa8f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js @@ -0,0 +1,22 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + // Use default mode: 'warn' - integration is active but should ignore AI_NoOutputGeneratedError +}); + +// Create an error with the name that should be ignored by default +class AI_NoOutputGeneratedError extends Error { + constructor(message) { + super(message); + this.name = 'AI_NoOutputGeneratedError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by default and not produce a warning +Promise.reject(new AI_NoOutputGeneratedError('Stream aborted')); 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 d3c8b4d599ff..cd0627664ea3 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 @@ -178,4 +178,32 @@ test rejection`); 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('should not warn when AI_NoOutputGeneratedError is rejected (default ignore)', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-default.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); + + test('should not warn when custom ignored error by name is rejected', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-custom-name.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); }); diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index dbddb2a4c396..42a17e2e6c7e 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -1,24 +1,41 @@ import type { Client, IntegrationFn, SeverityLevel, Span } from '@sentry/core'; -import { captureException, consoleSandbox, defineIntegration, getClient, withActiveSpan } from '@sentry/core'; +import { + captureException, + consoleSandbox, + defineIntegration, + getClient, + isMatchingPattern, + withActiveSpan, +} from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; +type IgnoreMatcher = { name?: string | RegExp; message?: string | RegExp }; + interface OnUnhandledRejectionOptions { /** * Option deciding what to do after capturing unhandledRejection, * that mimicks behavior of node's --unhandled-rejection flag. */ mode: UnhandledRejectionMode; + /** Rejection Errors to ignore (don't capture or warn). */ + ignore?: IgnoreMatcher[]; } const INTEGRATION_NAME = 'OnUnhandledRejection'; +const DEFAULT_IGNORES: IgnoreMatcher[] = [ + { + name: 'AI_NoOutputGeneratedError', // When stream aborts in Vercel AI SDK, Vercel flush() fails with an error + }, +]; + const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { - const opts = { - mode: 'warn', - ...options, - } satisfies OnUnhandledRejectionOptions; + const opts: OnUnhandledRejectionOptions = { + mode: options.mode ?? 'warn', + ignore: [...DEFAULT_IGNORES, ...(options.ignore ?? [])], + }; return { name: INTEGRATION_NAME, @@ -28,27 +45,54 @@ const _onUnhandledRejectionIntegration = ((options: Partial; + const name = typeof errorLike.name === 'string' ? errorLike.name : ''; + const message = typeof errorLike.message === 'string' ? errorLike.message : String(reason); + + return { name, message }; +} + +/** Check if a matcher matches the reason */ +function isMatchingReason(matcher: IgnoreMatcher, errorInfo: ReturnType): boolean { + // name/message matcher + const nameMatches = matcher.name === undefined || isMatchingPattern(errorInfo.name, matcher.name, true); + + const messageMatches = matcher.message === undefined || isMatchingPattern(errorInfo.message, matcher.message); + + return nameMatches && messageMatches; +} + +/** Match helper */ +function matchesIgnore(list: IgnoreMatcher[], reason: unknown): boolean { + const errorInfo = extractErrorInfo(reason); + return list.some(matcher => isMatchingReason(matcher, errorInfo)); +} + +/** Core handler */ export function makeUnhandledPromiseHandler( client: Client, options: OnUnhandledRejectionOptions, ): (reason: unknown, promise: unknown) => void { return function sendUnhandledPromise(reason: unknown, promise: unknown): void { + // Only handle for the active client if (getClient() !== client) { return; } + // Skip if configured to ignore + if (matchesIgnore(options.ignore ?? [], reason)) { + return; + } + const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; // this can be set in places where we cannot reliably get access to the active span/error