diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index b39baee2a3fe..fcb7808178f2 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -48,10 +48,7 @@ export function exceptionFromError(stackParser: StackParser, ex: Error): Excepti return exception; } -/** - * @hidden - */ -export function eventFromPlainObject( +function eventFromPlainObject( stackParser: StackParser, exception: Record, syntheticException?: Error, @@ -60,35 +57,46 @@ export function eventFromPlainObject( const client = getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; - const event: Event = { + // If we can, we extract an exception from the object properties + const errorFromProp = getErrorPropertyFromObject(exception); + + const extra = { + __serialized__: normalizeToSize(exception, normalizeDepth), + }; + + if (errorFromProp) { + return { + exception: { + values: [exceptionFromError(stackParser, errorFromProp)], + }, + extra, + }; + } + + const event = { exception: { values: [ { type: isEvent(exception) ? exception.constructor.name : isUnhandledRejection ? 'UnhandledRejection' : 'Error', value: getNonErrorObjectExceptionValue(exception, { isUnhandledRejection }), - }, + } as Exception, ], }, - extra: { - __serialized__: normalizeToSize(exception, normalizeDepth), - }, - }; + extra, + } satisfies Event; if (syntheticException) { const frames = parseStackFrames(stackParser, syntheticException); if (frames.length) { // event.exception.values[0] has been set above - (event.exception as { values: Exception[] }).values[0].stacktrace = { frames }; + event.exception.values[0].stacktrace = { frames }; } } return event; } -/** - * @hidden - */ -export function eventFromError(stackParser: StackParser, ex: Error): Event { +function eventFromError(stackParser: StackParser, ex: Error): Event { return { exception: { values: [exceptionFromError(stackParser, ex)], @@ -97,7 +105,7 @@ export function eventFromError(stackParser: StackParser, ex: Error): Event { } /** Parses stack frames from an error */ -export function parseStackFrames( +function parseStackFrames( stackParser: StackParser, ex: Error & { framesToPop?: number; stacktrace?: string }, ): StackFrame[] { @@ -283,10 +291,7 @@ export function eventFromUnknownInput( return event; } -/** - * @hidden - */ -export function eventFromString( +function eventFromString( stackParser: StackParser, message: ParameterizedString, syntheticException?: Error, @@ -346,3 +351,17 @@ function getObjectClassName(obj: unknown): string | undefined | void { // ignore errors here } } + +/** If a plain object has a property that is an `Error`, return this error. */ +function getErrorPropertyFromObject(obj: Record): Error | undefined { + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + const value = obj[prop]; + if (value instanceof Error) { + return value; + } + } + } + + return undefined; +} diff --git a/packages/browser/test/unit/eventbuilder.test.ts b/packages/browser/test/unit/eventbuilder.test.ts index 0a5a7911ea08..d941c792aeff 100644 --- a/packages/browser/test/unit/eventbuilder.test.ts +++ b/packages/browser/test/unit/eventbuilder.test.ts @@ -1,5 +1,5 @@ import { defaultStackParser } from '../../src'; -import { eventFromPlainObject } from '../../src/eventbuilder'; +import { eventFromUnknownInput } from '../../src/eventbuilder'; jest.mock('@sentry/core', () => { const original = jest.requireActual('@sentry/core'); @@ -12,17 +12,6 @@ jest.mock('@sentry/core', () => { }, }; }, - getCurrentHub() { - return { - getClient(): any { - return { - getOptions(): any { - return { normalizeDepth: 6 }; - }, - }; - }, - }; - }, }; }); @@ -35,7 +24,7 @@ afterEach(() => { jest.resetAllMocks(); }); -describe('eventFromPlainObject', () => { +describe('eventFromUnknownInput', () => { it('should use normalizeDepth from init options', () => { const deepObject = { a: { @@ -53,7 +42,7 @@ describe('eventFromPlainObject', () => { }, }; - const event = eventFromPlainObject(defaultStackParser, deepObject); + const event = eventFromUnknownInput(defaultStackParser, deepObject); expect(event?.extra?.__serialized__).toEqual({ a: { @@ -71,16 +60,107 @@ describe('eventFromPlainObject', () => { }); it.each([ - ['empty object', {}, 'Object captured as exception with keys: [object has no keys]'], - ['pojo', { prop1: 'hello', prop2: 2 }, 'Object captured as exception with keys: prop1, prop2'], - ['Custom Class', new MyTestClass(), 'Object captured as exception with keys: prop1, prop2'], - ['Event', new Event('custom'), 'Event `Event` (type=custom) captured as exception'], - ['MouseEvent', new MouseEvent('click'), 'Event `MouseEvent` (type=click) captured as exception'], - ] as [string, Record, string][])( + ['empty object', {}, {}, 'Object captured as exception with keys: [object has no keys]'], + [ + 'pojo', + { prop1: 'hello', prop2: 2 }, + { prop1: 'hello', prop2: 2 }, + 'Object captured as exception with keys: prop1, prop2', + ], + [ + 'Custom Class', + new MyTestClass(), + { prop1: 'hello', prop2: 2 }, + 'Object captured as exception with keys: prop1, prop2', + ], + [ + 'Event', + new Event('custom'), + { + currentTarget: '[object Null]', + isTrusted: false, + target: '[object Null]', + type: 'custom', + }, + 'Event `Event` (type=custom) captured as exception', + ], + [ + 'MouseEvent', + new MouseEvent('click'), + { + currentTarget: '[object Null]', + isTrusted: false, + target: '[object Null]', + type: 'click', + }, + 'Event `MouseEvent` (type=click) captured as exception', + ], + ] as [string, Record, Record, string][])( 'has correct exception value for %s', - (_name, exception, expected) => { - const actual = eventFromPlainObject(defaultStackParser, exception); + (_name, exception, serializedException, expected) => { + const actual = eventFromUnknownInput(defaultStackParser, exception); expect(actual.exception?.values?.[0]?.value).toEqual(expected); + + expect(actual.extra).toEqual({ + __serialized__: serializedException, + }); }, ); + + it('handles object with error prop', () => { + const error = new Error('Some error'); + const event = eventFromUnknownInput(defaultStackParser, { + foo: { bar: 'baz' }, + name: 'BadType', + err: error, + }); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + foo: { bar: 'baz' }, + name: 'BadType', + err: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); + }); + + it('handles class with error prop', () => { + const error = new Error('Some error'); + + class MyTestClass { + prop1 = 'hello'; + prop2 = error; + } + + const event = eventFromUnknownInput(defaultStackParser, new MyTestClass()); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + prop1: 'hello', + prop2: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); + }); }); diff --git a/packages/utils/src/eventbuilder.ts b/packages/utils/src/eventbuilder.ts index af1bb60cd09c..ecfb98e80783 100644 --- a/packages/utils/src/eventbuilder.ts +++ b/packages/utils/src/eventbuilder.ts @@ -11,7 +11,7 @@ import type { StackParser, } from '@sentry/types'; -import { isError, isParameterizedString, isPlainObject } from './is'; +import { isError, isErrorEvent, isParameterizedString, isPlainObject } from './is'; import { addExceptionMechanism, addExceptionTypeValue } from './misc'; import { normalizeToSize } from './normalize'; import { extractExceptionKeysForMessage } from './object'; @@ -40,7 +40,21 @@ export function exceptionFromError(stackParser: StackParser, error: Error): Exce return exception; } -function getMessageForObject(exception: object): string { +/** If a plain object has a property that is an `Error`, return this error. */ +function getErrorPropertyFromObject(obj: Record): Error | undefined { + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop)) { + const value = obj[prop]; + if (value instanceof Error) { + return value; + } + } + } + + return undefined; +} + +function getMessageForObject(exception: Record): string { if ('name' in exception && typeof exception.name === 'string') { let message = `'${exception.name}' captured as exception`; @@ -51,13 +65,67 @@ function getMessageForObject(exception: object): string { return message; } else if ('message' in exception && typeof exception.message === 'string') { return exception.message; - } else { - // This will allow us to group events based on top-level keys - // which is much better than creating new group when any key/value change - return `Object captured as exception with keys: ${extractExceptionKeysForMessage( - exception as Record, - )}`; } + + const keys = extractExceptionKeysForMessage(exception); + + // Some ErrorEvent instances do not have an `error` property, which is why they are not handled before + // We still want to try to get a decent message for these cases + if (isErrorEvent(exception)) { + return `Event \`ErrorEvent\` captured as exception with message \`${exception.message}\``; + } + + const className = getObjectClassName(exception); + + return `${ + className && className !== 'Object' ? `'${className}'` : 'Object' + } captured as exception with keys: ${keys}`; +} + +function getObjectClassName(obj: unknown): string | undefined | void { + try { + const prototype: unknown | null = Object.getPrototypeOf(obj); + return prototype ? prototype.constructor.name : undefined; + } catch (e) { + // ignore errors here + } +} + +function getException( + client: Client, + mechanism: Mechanism, + exception: unknown, + hint?: EventHint, +): [Error, Extras | undefined] { + if (isError(exception)) { + return [exception, undefined]; + } + + // Mutate this! + mechanism.synthetic = true; + + if (isPlainObject(exception)) { + const normalizeDepth = client && client.getOptions().normalizeDepth; + const extras = { ['__serialized__']: normalizeToSize(exception as Record, normalizeDepth) }; + + const errorFromProp = getErrorPropertyFromObject(exception); + if (errorFromProp) { + return [errorFromProp, extras]; + } + + const message = getMessageForObject(exception); + const ex = (hint && hint.syntheticException) || new Error(message); + ex.message = message; + + return [ex, extras]; + } + + // This handles when someone does: `throw "something awesome";` + // We use synthesized Error here so we can extract a (rough) stack trace. + const ex = (hint && hint.syntheticException) || new Error(exception as string); + ex.message = `${exception}`; + + return [ex, undefined]; } /** @@ -70,7 +138,6 @@ export function eventFromUnknownInput( exception: unknown, hint?: EventHint, ): Event { - let ex: unknown = exception; const providedMechanism: Mechanism | undefined = hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism; const mechanism: Mechanism = providedMechanism || { @@ -78,28 +145,11 @@ export function eventFromUnknownInput( type: 'generic', }; - let extras: Extras | undefined; - - if (!isError(exception)) { - if (isPlainObject(exception)) { - const normalizeDepth = client && client.getOptions().normalizeDepth; - extras = { ['__serialized__']: normalizeToSize(exception as Record, normalizeDepth) }; - - const message = getMessageForObject(exception); - ex = (hint && hint.syntheticException) || new Error(message); - (ex as Error).message = message; - } else { - // This handles when someone does: `throw "something awesome";` - // We use synthesized Error here so we can extract a (rough) stack trace. - ex = (hint && hint.syntheticException) || new Error(exception as string); - (ex as Error).message = exception as string; - } - mechanism.synthetic = true; - } + const [ex, extras] = getException(client, mechanism, exception, hint); const event: Event = { exception: { - values: [exceptionFromError(stackParser, ex as Error)], + values: [exceptionFromError(stackParser, ex)], }, }; diff --git a/packages/utils/test/eventbuilder.test.ts b/packages/utils/test/eventbuilder.test.ts index 3f3f5c479c98..c09697366b6f 100644 --- a/packages/utils/test/eventbuilder.test.ts +++ b/packages/utils/test/eventbuilder.test.ts @@ -4,30 +4,150 @@ import { createStackParser, eventFromUnknownInput, nodeStackLineParser } from '. const stackParser = createStackParser(nodeStackLineParser()); +class MyTestClass { + prop1 = 'hello'; + prop2 = 2; +} + describe('eventFromUnknownInput', () => { const fakeClient = { getOptions: () => ({}), } as Client; test('object with useless props', () => { const event = eventFromUnknownInput(fakeClient, stackParser, { foo: { bar: 'baz' }, prop: 1 }); - expect(event.exception?.values?.[0].value).toBe('Object captured as exception with keys: foo, prop'); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Object captured as exception with keys: foo, prop', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { foo: { bar: 'baz' }, prop: 1 }, + }); }); test('object with name prop', () => { const event = eventFromUnknownInput(fakeClient, stackParser, { foo: { bar: 'baz' }, name: 'BadType' }); expect(event.exception?.values?.[0].value).toBe("'BadType' captured as exception"); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: "'BadType' captured as exception", + }), + ); + expect(event.extra).toEqual({ + __serialized__: { foo: { bar: 'baz' }, name: 'BadType' }, + }); }); test('object with name and message props', () => { const event = eventFromUnknownInput(fakeClient, stackParser, { message: 'went wrong', name: 'BadType' }); expect(event.exception?.values?.[0].value).toBe("'BadType' captured as exception with message 'went wrong'"); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: "'BadType' captured as exception with message 'went wrong'", + }), + ); + expect(event.extra).toEqual({ + __serialized__: { message: 'went wrong', name: 'BadType' }, + }); }); test('object with message prop', () => { const event = eventFromUnknownInput(fakeClient, stackParser, { foo: { bar: 'baz' }, message: 'Some message' }); - expect(event.exception?.values?.[0].value).toBe('Some message'); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some message', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { foo: { bar: 'baz' }, message: 'Some message' }, + }); + }); + + test('object with error prop', () => { + const error = new Error('Some error'); + const event = eventFromUnknownInput(fakeClient, stackParser, { + foo: { bar: 'baz' }, + name: 'BadType', + err: error, + }); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + foo: { bar: 'baz' }, + name: 'BadType', + err: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); + }); + + it('handles class with error prop', () => { + const error = new Error('Some error'); + + class MyTestClass { + prop1 = 'hello'; + prop2 = error; + } + + const event = eventFromUnknownInput(fakeClient, stackParser, new MyTestClass()); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + mechanism: { handled: true, synthetic: true, type: 'generic' }, + type: 'Error', + value: 'Some error', + }), + ); + expect(event.extra).toEqual({ + __serialized__: { + prop1: 'hello', + prop2: { + message: 'Some error', + name: 'Error', + stack: expect.stringContaining('Error: Some error'), + }, + }, + }); }); + it.each([ + ['empty object', {}, 'Object captured as exception with keys: [object has no keys]'], + ['pojo', { prop1: 'hello', prop2: 2 }, 'Object captured as exception with keys: prop1, prop2'], + ['Custom Class', new MyTestClass(), "'MyTestClass' captured as exception with keys: prop1, prop2"], + ] as [string, Record, string][])( + 'has correct exception value for %s', + (_name, exception, expected) => { + const actual = eventFromUnknownInput(fakeClient, stackParser, exception); + expect(actual.exception?.values?.[0]?.value).toEqual(expected); + + expect(actual.extra).toEqual({ + __serialized__: exception, + }); + }, + ); + test('passing client directly', () => { const event = eventFromUnknownInput(fakeClient, stackParser, { foo: { bar: 'baz' }, prop: 1 }); expect(event.exception?.values?.[0].value).toBe('Object captured as exception with keys: foo, prop');