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 @@
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);
}
Original file line number Diff line number Diff line change
@@ -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.stringContaining('suppressed during disposal'),
mechanism: { type: 'generic', handled: true, exception_id: 0 },
},
],
},
},
})
.start()
.completed();
});
});
30 changes: 30 additions & 0 deletions packages/core/src/utils/aggregate-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,43 @@ 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,
);
}
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

return newExceptions;
}

function isExceptionGroup(error: ExtendedError): error is ExtendedError & { errors: unknown[] } {
return Array.isArray(error.errors);
}

function isSuppressedError(error: ExtendedError): boolean {
return 'error' in error && 'suppressed' in error;
}

function applyExceptionGroupFieldsForParentException(
exception: Exception,
exceptionId: number,
Expand Down
84 changes: 84 additions & 0 deletions packages/core/test/lib/utils/aggregate-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});