From 63da54c3718ea9dd9c35c1b531651fe2a6609c53 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 6 Jul 2023 17:21:49 +0200 Subject: [PATCH] feat: Support `AggregateErrors` in `LinkedErrors` integration (#8463) --- packages/types/src/mechanism.ts | 25 +++ packages/utils/src/aggregate-errors.ts | 115 ++++++++++-- packages/utils/test/aggregate-errors.test.ts | 186 ++++++++++++++++++- 3 files changed, 302 insertions(+), 24 deletions(-) diff --git a/packages/types/src/mechanism.ts b/packages/types/src/mechanism.ts index ee90d2eda762..0f2adf98ed24 100644 --- a/packages/types/src/mechanism.ts +++ b/packages/types/src/mechanism.ts @@ -29,4 +29,29 @@ export interface Mechanism { * to recreate the stacktrace. */ synthetic?: boolean; + + /** + * Describes the source of the exception, in the case that this is a derived (linked or aggregate) error. + * + * This should be populated with the name of the property where the exception was found on the parent exception. + * E.g. "cause", "errors[0]", "errors[1]" + */ + source?: string; + + /** + * Indicates whether the exception is an `AggregateException`. + */ + is_exception_group?: boolean; + + /** + * An identifier for the exception inside the `event.exception.values` array. This identifier is referenced to via the + * `parent_id` attribute to link and aggregate errors. + */ + exception_id?: number; + + /** + * References another exception via the `exception_id` field to indicate that this excpetion is a child of that + * exception in the case of aggregate or linked errors. + */ + parent_id?: number; } diff --git a/packages/utils/src/aggregate-errors.ts b/packages/utils/src/aggregate-errors.ts index bc61ba49de41..1dcf9b1628ef 100644 --- a/packages/utils/src/aggregate-errors.ts +++ b/packages/utils/src/aggregate-errors.ts @@ -12,22 +12,28 @@ export function applyAggregateErrorsToEvent( limit: number, event: Event, hint?: EventHint, -): Event | null { +): void { if (!event.exception || !event.exception.values || !hint || !isInstanceOf(hint.originalException, Error)) { - return event; + return; } - const linkedErrors = aggregateExceptionsFromError( - exceptionFromErrorImplementation, - parser, - limit, - hint.originalException as ExtendedError, - key, - ); + // Generally speaking the last item in `event.exception.values` is the exception originating from the original Error + const originalException: Exception | undefined = + event.exception.values.length > 0 ? event.exception.values[event.exception.values.length - 1] : undefined; - event.exception.values = [...linkedErrors, ...event.exception.values]; - - return event; + // We only create exception grouping if there is an exception in the event. + if (originalException) { + event.exception.values = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + hint.originalException as ExtendedError, + key, + event.exception.values, + originalException, + 0, + ); + } } function aggregateExceptionsFromError( @@ -36,15 +42,84 @@ function aggregateExceptionsFromError( limit: number, error: ExtendedError, key: string, - stack: Exception[] = [], + prevExceptions: Exception[], + exception: Exception, + exceptionId: number, ): Exception[] { - if (!isInstanceOf(error[key], Error) || stack.length >= limit) { - return stack; + if (prevExceptions.length >= limit + 1) { + return prevExceptions; + } + + let newExceptions = [...prevExceptions]; + + if (isInstanceOf(error[key], Error)) { + applyExceptionGroupFieldsForParentException(exception, exceptionId); + const newException = exceptionFromErrorImplementation(parser, error[key]); + const newExceptionId = newExceptions.length; + applyExceptionGroupFieldsForChildException(newException, key, newExceptionId, exceptionId); + newExceptions = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + error[key], + key, + [newException, ...newExceptions], + newException, + newExceptionId, + ); + } + + // This will create exception grouping for AggregateErrors + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError + if (Array.isArray(error.errors)) { + error.errors.forEach((childError, i) => { + if (isInstanceOf(childError, Error)) { + applyExceptionGroupFieldsForParentException(exception, exceptionId); + const newException = exceptionFromErrorImplementation(parser, childError); + const newExceptionId = newExceptions.length; + applyExceptionGroupFieldsForChildException(newException, `errors[${i}]`, newExceptionId, exceptionId); + newExceptions = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + childError, + key, + [newException, ...newExceptions], + newException, + newExceptionId, + ); + } + }); } - const exception = exceptionFromErrorImplementation(parser, error[key]); - return aggregateExceptionsFromError(exceptionFromErrorImplementation, parser, limit, error[key], key, [ - exception, - ...stack, - ]); + return newExceptions; +} + +function applyExceptionGroupFieldsForParentException(exception: Exception, exceptionId: number): void { + // Don't know if this default makes sense. The protocol requires us to set these values so we pick *some* default. + exception.mechanism = exception.mechanism || { type: 'generic', handled: true }; + + exception.mechanism = { + ...exception.mechanism, + is_exception_group: true, + exception_id: exceptionId, + }; +} + +function applyExceptionGroupFieldsForChildException( + exception: Exception, + source: string, + exceptionId: number, + parentId: number | undefined, +): void { + // Don't know if this default makes sense. The protocol requires us to set these values so we pick *some* default. + exception.mechanism = exception.mechanism || { type: 'generic', handled: true }; + + exception.mechanism = { + ...exception.mechanism, + type: 'chained', + source, + exception_id: exceptionId, + parent_id: parentId, + }; } diff --git a/packages/utils/test/aggregate-errors.test.ts b/packages/utils/test/aggregate-errors.test.ts index 2f84c3629982..9a42bba12858 100644 --- a/packages/utils/test/aggregate-errors.test.ts +++ b/packages/utils/test/aggregate-errors.test.ts @@ -4,7 +4,7 @@ import { applyAggregateErrorsToEvent, createStackParser } from '../src/index'; const stackParser = createStackParser([0, line => ({ filename: line })]); const exceptionFromError = (_stackParser: StackParser, ex: Error): Exception => { - return { value: ex.message }; + return { value: ex.message, mechanism: { type: 'instrument', handled: true } }; }; describe('applyAggregateErrorsToEvent()', () => { @@ -46,28 +46,63 @@ describe('applyAggregateErrorsToEvent()', () => { test('should recursively walk the original exception based on the `key` option and add them as exceptions to the event', () => { const key = 'cause'; const originalException: ExtendedError = new Error('Root Error'); - const event: Event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; originalException[key] = new Error('Nested Error 1'); originalException[key][key] = new Error('Nested Error 2'); + + const event: Event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; const eventHint: EventHint = { originalException }; + applyAggregateErrorsToEvent(exceptionFromError, stackParser, key, 100, event, eventHint); expect(event).toStrictEqual({ exception: { values: [ { value: 'Nested Error 2', + mechanism: { + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }, }, { value: 'Nested Error 1', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + is_exception_group: true, + source: 'cause', + type: 'chained', + }, }, { value: 'Root Error', + mechanism: { + exception_id: 0, + handled: true, + is_exception_group: true, + type: 'instrument', + }, }, ], }, }); }); + test('should not modify event if there are no attached errors', () => { + const originalException: ExtendedError = new Error('Some Error'); + + const event: Event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + const eventHint: EventHint = { originalException }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + + // no changes + expect(event).toStrictEqual({ exception: { values: [exceptionFromError(stackParser, originalException)] } }); + }); + test('should allow to limit number of attached errors', () => { const key = 'cause'; const originalException: ExtendedError = new Error('Root Error'); @@ -89,9 +124,152 @@ describe('applyAggregateErrorsToEvent()', () => { // Last exception in list should be the root exception expect(event.exception?.values?.[event.exception?.values.length - 1]).toStrictEqual({ value: 'Root Error', + mechanism: { + exception_id: 0, + handled: true, + is_exception_group: true, + type: 'instrument', + }, }); }); - test.todo('should recursively walk AggregateErrors and add them as exceptions to the event'); - test.todo('should recursively walk mixed errors (Aggregate errors and based on `key`)'); + test('should keep the original mechanism type for the root exception', () => { + const fakeAggregateError: ExtendedError = new Error('Root Error'); + fakeAggregateError.errors = [new Error('Nested Error 1'), new Error('Nested Error 2')]; + + const event: Event = { exception: { values: [exceptionFromError(stackParser, fakeAggregateError)] } }; + const eventHint: EventHint = { originalException: fakeAggregateError }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + expect(event.exception?.values?.[event.exception.values.length - 1].mechanism?.type).toBe('instrument'); + }); + + test('should recursively walk mixed errors (Aggregate errors and based on `key`)', () => { + const chainedError: ExtendedError = new Error('Nested Error 3'); + chainedError.cause = new Error('Nested Error 4'); + const fakeAggregateError2: ExtendedError = new Error('AggregateError2'); + fakeAggregateError2.errors = [new Error('Nested Error 2'), chainedError]; + const fakeAggregateError1: ExtendedError = new Error('AggregateError1'); + fakeAggregateError1.errors = [new Error('Nested Error 1'), fakeAggregateError2]; + + const event: Event = { exception: { values: [exceptionFromError(stackParser, fakeAggregateError1)] } }; + const eventHint: EventHint = { originalException: fakeAggregateError1 }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + expect(event).toStrictEqual({ + exception: { + values: [ + { + mechanism: { + exception_id: 5, + handled: true, + parent_id: 4, + source: 'cause', + type: 'chained', + }, + value: 'Nested Error 4', + }, + { + mechanism: { + exception_id: 4, + handled: true, + is_exception_group: true, + parent_id: 2, + source: 'errors[1]', + type: 'chained', + }, + value: 'Nested Error 3', + }, + { + mechanism: { + exception_id: 3, + handled: true, + parent_id: 2, + source: 'errors[0]', + type: 'chained', + }, + value: 'Nested Error 2', + }, + { + mechanism: { + exception_id: 2, + handled: true, + is_exception_group: true, + parent_id: 0, + source: 'errors[1]', + type: 'chained', + }, + value: 'AggregateError2', + }, + { + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + source: 'errors[0]', + type: 'chained', + }, + value: 'Nested Error 1', + }, + { + mechanism: { + exception_id: 0, + handled: true, + is_exception_group: true, + type: 'instrument', + }, + value: 'AggregateError1', + }, + ], + }, + }); + }); + + test('should keep the original mechanism type for the root exception', () => { + const key = 'cause'; + const originalException: ExtendedError = new Error('Root Error'); + originalException[key] = new Error('Nested Error 1'); + originalException[key][key] = new Error('Nested Error 2'); + + const event: Event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + const eventHint: EventHint = { originalException }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, key, 100, event, eventHint); + expect(event).toStrictEqual({ + exception: { + values: [ + { + value: 'Nested Error 2', + mechanism: { + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }, + }, + { + value: 'Nested Error 1', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + is_exception_group: true, + source: 'cause', + type: 'chained', + }, + }, + { + value: 'Root Error', + mechanism: { + exception_id: 0, + handled: true, + is_exception_group: true, + type: 'instrument', + }, + }, + ], + }, + }); + }); });