diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts new file mode 100644 index 000000000000..9d6526337eb4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts @@ -0,0 +1,27 @@ +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', + transport: loggingTransport, +}); + +function createResource() { + return { + [Symbol.dispose]() { + throw new Error('error from dispose'); + }, + }; +} + +function run(): void { + using _resource = createResource(); + throw new Error('original error'); +} + +try { + run(); +} catch (error) { + Sentry.captureException(error); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts new file mode 100644 index 000000000000..a482241adf20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts @@ -0,0 +1,38 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { supports } from '../../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe.runIf(supports({ min: 22 }))('SuppressedError', () => { + test('captures both errors of a SuppressedError as chained exceptions', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'original error', + mechanism: { type: 'chained', source: 'suppressed', handled: true, exception_id: 2, parent_id: 0 }, + }, + { + type: 'Error', + value: 'error from dispose', + mechanism: { type: 'chained', source: 'error', handled: true, exception_id: 1, parent_id: 0 }, + }, + { + type: 'SuppressedError', + value: expect.any(String), + mechanism: { type: 'generic', handled: true, exception_id: 0 }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/packages/core/src/utils/aggregate-errors.ts b/packages/core/src/utils/aggregate-errors.ts index ef006d12e69d..a481ea7ab28c 100644 --- a/packages/core/src/utils/aggregate-errors.ts +++ b/packages/core/src/utils/aggregate-errors.ts @@ -95,6 +95,32 @@ function aggregateExceptionsFromError( }); } + // SuppressedError (explicit resource management) wraps two errors: `error` (the new one thrown during disposal) + // and `suppressed` (the original one it replaced). + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SuppressedError + + if (isSuppressedError(error)) { + ['error', 'suppressed'].forEach(property => { + const childError = error[property]; + if (isError(childError)) { + applyExceptionGroupFieldsForParentException(exception, exceptionId, error); + const newException = exceptionFromErrorImplementation(parser, childError); + const newExceptionId = newExceptions.length; + applyExceptionGroupFieldsForChildException(newException, property, newExceptionId, exceptionId); + newExceptions = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + childError as ExtendedError, + key, + [newException, ...newExceptions], + newException, + newExceptionId, + ); + } + }); + } + return newExceptions; } @@ -102,6 +128,10 @@ function isExceptionGroup(error: ExtendedError): error is ExtendedError & { erro return Array.isArray(error.errors); } +function isSuppressedError(error: ExtendedError): boolean { + return 'error' in error && 'suppressed' in error; +} + function applyExceptionGroupFieldsForParentException( exception: Exception, exceptionId: number, diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index 86f796d2d8e0..2ca4891710a9 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -32,6 +32,18 @@ class CustomAggregateError extends FakeAggregateError { } } +class FakeSuppressedError extends Error { + public error: unknown; + public suppressed: unknown; + + constructor(error: unknown, suppressed: unknown, message = 'An error was suppressed during disposal.') { + super(message); + this.name = 'SuppressedError'; + this.error = error; + this.suppressed = suppressed; + } +} + describe('applyAggregateErrorsToEvent()', () => { test('should not do anything if event does not contain an exception', () => { const event: Event = { exception: undefined }; @@ -482,4 +494,76 @@ describe('applyAggregateErrorsToEvent()', () => { }, }); }); + + test('adds both the `error` and the `suppressed` error of a SuppressedError', () => { + const suppressedError = new FakeSuppressedError(new Error('Error from dispose'), new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + const eventHint: EventHint = { originalException: suppressedError }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + + expect(event).toStrictEqual({ + exception: { + values: [ + { + mechanism: { exception_id: 2, handled: true, parent_id: 0, source: 'suppressed', type: 'chained' }, + type: 'Error', + value: 'Original error', + }, + { + mechanism: { exception_id: 1, handled: true, parent_id: 0, source: 'error', type: 'chained' }, + type: 'Error', + value: 'Error from dispose', + }, + { + mechanism: { exception_id: 0, handled: true, type: 'instrument' }, + type: 'SuppressedError', + value: 'An error was suppressed during disposal.', + }, + ], + }, + }); + }); + + test('walks nested SuppressedErrors (several disposers throwing)', () => { + const inner = new FakeSuppressedError(new Error('dispose 2 failed'), new Error('Original error')); + const outer = new FakeSuppressedError(new Error('dispose 1 failed'), inner); + const event: Event = { exception: { values: [exceptionFromError(stackParser, outer)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { originalException: outer }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'dispose 2 failed', + 'An error was suppressed during disposal.', + 'dispose 1 failed', + 'An error was suppressed during disposal.', + ]); + }); + + test('skips `error` / `suppressed` values that are not Errors', () => { + const suppressedError = new FakeSuppressedError('just a string', new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: suppressedError, + }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'An error was suppressed during disposal.', + ]); + }); + + test('does not treat an error that only has an `error` property as a SuppressedError', () => { + const notSuppressed: ExtendedError = new Error('Something else'); + notSuppressed.error = new Error('unrelated'); + const event: Event = { exception: { values: [exceptionFromError(stackParser, notSuppressed)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: notSuppressed, + }); + + expect(event.exception?.values).toHaveLength(1); + }); });