From 7ad568e6d31866d1fd9c288e8c712229a93ff9c3 Mon Sep 17 00:00:00 2001 From: CheckPickerUpper <194722943+CheckPickerUpper@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:09:06 -0400 Subject: [PATCH] fix(core): Enforce linked-error limits across exception groups --- packages/core/src/utils/aggregate-errors.ts | 14 +++-- .../test/lib/utils/aggregate-errors.test.ts | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/aggregate-errors.ts b/packages/core/src/utils/aggregate-errors.ts index a481ea7ab28c..b8527bb94ddd 100644 --- a/packages/core/src/utils/aggregate-errors.ts +++ b/packages/core/src/utils/aggregate-errors.ts @@ -75,7 +75,10 @@ function aggregateExceptionsFromError( // This will create exception grouping for AggregateErrors // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError if (isExceptionGroup(error)) { - error.errors.forEach((childError, i) => { + const errors = error.errors; + const length = errors.length; + for (let i = 0; i < length && newExceptions.length < limit + 1; i++) { + const childError = errors[i]; if (isError(childError)) { applyExceptionGroupFieldsForParentException(exception, exceptionId, error); const newException = exceptionFromErrorImplementation(parser, childError); @@ -92,7 +95,7 @@ function aggregateExceptionsFromError( newExceptionId, ); } - }); + } } // SuppressedError (explicit resource management) wraps two errors: `error` (the new one thrown during disposal) @@ -100,7 +103,10 @@ function aggregateExceptionsFromError( // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SuppressedError if (isSuppressedError(error)) { - ['error', 'suppressed'].forEach(property => { + for (const property of ['error', 'suppressed']) { + if (newExceptions.length >= limit + 1) { + break; + } const childError = error[property]; if (isError(childError)) { applyExceptionGroupFieldsForParentException(exception, exceptionId, error); @@ -118,7 +124,7 @@ function aggregateExceptionsFromError( newExceptionId, ); } - }); + } } return newExceptions; diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index 2ca4891710a9..09bd4cd965e3 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -273,6 +273,44 @@ describe('applyAggregateErrorsToEvent()', () => { }); }); + test.each([ + [0, ['Root Error']], + [1, ['First child', 'Root Error']], + [3, ['Third child', 'Second child', 'First child', 'Root Error']], + ])('limits AggregateError children to %i linked errors', (limit, expectedValues) => { + const originalException: ExtendedError = runInNewContext( + `new AggregateError(['not an error', new Error('First child'), new Error('Second child'), new Error('Third child'), new Error('Fourth child')], 'Root Error')`, + ); + const event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', limit, event, { originalException }); + + expect(event.exception.values.map(exception => exception.value)).toEqual(expectedValues); + }); + + test('stops reading aggregate children when a nested cause uses the linked-error limit', () => { + const cause = new FakeAggregateError( + [new Error('First child'), new Error('Second child'), new Error('Third child')], + 'Cause', + ); + const originalException = new CustomAggregateError([new Error('Unread child')], 'Root Error', cause); + Object.defineProperty(originalException.errors, 0, { + get() { + throw new Error('Read an aggregate child after reaching the limit'); + }, + }); + const event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 3, event, { originalException }); + + expect(event.exception.values.map(exception => exception.value)).toEqual([ + 'Second child', + 'First child', + 'Cause', + 'Root Error', + ]); + }); + test('should keep the original mechanism type for the root exception', () => { const fakeAggregateError = new FakeAggregateError( [new Error('Nested Error 1'), new Error('Nested Error 2')], @@ -541,6 +579,21 @@ describe('applyAggregateErrorsToEvent()', () => { ]); }); + test('limits nested SuppressedErrors before reading later siblings', () => { + const inner = new FakeSuppressedError(new Error('dispose failed'), new Error('Unread inner error'), 'Inner'); + const originalException = new FakeSuppressedError(inner, new Error('Unread outer error'), 'Root Error'); + Object.defineProperty(inner, 'suppressed', { + get() { + throw new Error('Read a suppressed error after reaching the limit'); + }, + }); + const event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 2, event, { originalException }); + + expect(event.exception.values.map(exception => exception.value)).toEqual(['dispose failed', 'Inner', 'Root Error']); + }); + 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)] } };