Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'));
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const Sentry = require('@sentry/node');

const IGNORE_SYMBOL = Symbol('ignore');

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [
Sentry.onUnhandledRejectionIntegration({
// Use default mode: 'warn' - integration is active but should ignore errors with the symbol
ignore: [{ symbol: IGNORE_SYMBOL }],
}),
],
});

// Create an error with the ignore symbol
class CustomError extends Error {
constructor(message) {
super(message);
this.name = 'CustomError';
this[IGNORE_SYMBOL] = true;
}
}

setTimeout(() => {
process.stdout.write("I'm alive!");
process.exit(0);
}, 500);

// This should be ignored by the symbol matcher and not produce a warning
Promise.reject(new CustomError('This error should be ignored by symbol'));
Original file line number Diff line number Diff line change
@@ -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'));
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,46 @@ 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<void>(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<void>(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();
});
}));

test('should not warn when custom ignored error by symbol is rejected', () =>
new Promise<void>(done => {
expect.assertions(3);

const testScriptPath = path.resolve(__dirname, 'ignore-custom-symbol.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();
});
}));
});
81 changes: 64 additions & 17 deletions packages/node-core/src/integrations/onunhandledrejection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,31 @@ import { logAndExitProcess } from '../utils/errorhandling';

type UnhandledRejectionMode = 'none' | 'warn' | 'strict';

type IgnoreMatcher = { symbol: symbol } | { 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<OnUnhandledRejectionOptions> = {}) => {
const opts = {
mode: 'warn',
...options,
} satisfies OnUnhandledRejectionOptions;
const opts: OnUnhandledRejectionOptions = {
mode: options.mode ?? 'warn',
ignore: [...DEFAULT_IGNORES, ...(options.ignore ?? [])],
};

return {
name: INTEGRATION_NAME,
Expand All @@ -28,26 +38,63 @@ const _onUnhandledRejectionIntegration = ((options: Partial<OnUnhandledRejection
};
}) satisfies IntegrationFn;

/**
* Add a global promise rejection handler.
*/
export const onUnhandledRejectionIntegration = defineIntegration(_onUnhandledRejectionIntegration);

/**
* Send an exception with reason
* @param reason string
* @param promise promise
*
* Exported only for tests.
*/
/** Extract error info safely */
function extractErrorInfo(reason: unknown): { name: string; message: string; isObject: boolean } {
const isObject = reason !== null && typeof reason === 'object';
if (!isObject) {
return { name: '', message: String(reason ?? ''), isObject };
}

const errorLike = reason as Record<string, unknown>;
const name = typeof errorLike.name === 'string' ? errorLike.name : '';
const message = typeof errorLike.message === 'string' ? errorLike.message : String(reason);

return { name, message, isObject };
}

/** Check if a matcher matches the reason */
function isMatchingReason(
matcher: IgnoreMatcher,
reason: unknown,
errorInfo: ReturnType<typeof extractErrorInfo>,
): boolean {
if ('symbol' in matcher) {
return errorInfo.isObject && matcher.symbol in (reason as object);
}

// name/message matcher
const nameMatches =
matcher.name === undefined ||
(typeof matcher.name === 'string' ? errorInfo.name === matcher.name : matcher.name.test(errorInfo.name));

const messageMatches =
matcher.message === undefined ||
(typeof matcher.message === 'string'
? errorInfo.message.includes(matcher.message)
: matcher.message.test(errorInfo.message));

return nameMatches && messageMatches;
}

/** Match helper */
function matchesIgnore(reason: unknown, list: IgnoreMatcher[]): boolean {
const errorInfo = extractErrorInfo(reason);
return list.some(matcher => isMatchingReason(matcher, reason, errorInfo));
}

/** Core handler */
export function makeUnhandledPromiseHandler(
client: Client,
options: OnUnhandledRejectionOptions,
): (reason: unknown, promise: unknown) => void {
return function sendUnhandledPromise(reason: unknown, promise: unknown): void {
if (getClient() !== client) {
return;
}
// Only handle for the active client
if (getClient() !== client) return;

// Skip if configured to ignore
if (matchesIgnore(reason, options.ignore ?? [])) return;

const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error';

Expand Down