diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx index b9dd782d8a653..ff3d575df5686 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/index.tsx @@ -20,6 +20,7 @@ export * from './errors'; * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead */ export interface AppToast extends Toast { + // FunFact: In a very rare case of errors this can be something other than array. We have a unit test case for it and am leaving it like this type for now. errors?: string[]; } diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx index 7ec0553591103..94cf94ed46da7 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.test.tsx @@ -54,6 +54,18 @@ describe('Modal all errors', () => { mockToastWithTwoError.errors.length ); }); + + // This test exists to ensure that errors will work if it is a non-array which can happen in rare corner cases. + test('it doesnt cause errors when errors is not an array which can be the rare case in corner cases', () => { + const mockToastWithTwoError = cloneDeep(mockToast); + mockToastWithTwoError.errors = ('' as unknown) as string[]; + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="modal-all-errors-accordion"]').length).toBe( + mockToastWithTwoError.errors.length + ); + }); }); describe('events', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx index 0a78139f5fe3a..29058a87a96b5 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx @@ -23,12 +23,18 @@ import styled from 'styled-components'; import { AppToast } from '.'; import * as i18n from './translations'; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ interface FullErrorProps { isShowing: boolean; toast: AppToast; toggle: (toast: AppToast) => void; } +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, toggle }) => { const handleClose = useCallback(() => toggle(toast), [toggle, toast]); @@ -43,7 +49,7 @@ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, t - {toast.errors != null && + {Array.isArray(toast.errors) && // FunFact: This can be a non-array in some rare cases toast.errors.map((error, index) => ( = ({ isShowing, toast, t ); }; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ export const ModalAllErrors = React.memo(ModalAllErrorsComponent); +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ const MyEuiCodeBlock = styled(EuiCodeBlock)` margin-top: 4px; `; +/** + * @deprecated Use x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts instead + */ MyEuiCodeBlock.displayName = 'MyEuiCodeBlock'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index 27f584bb17248..da6b41080c1c7 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -9,7 +9,19 @@ import { renderHook } from '@testing-library/react-hooks'; import { IEsError } from 'src/plugins/data/public'; import { useToasts } from '../lib/kibana'; -import { useAppToasts } from './use_app_toasts'; +import { KibanaError, SecurityAppError } from '../utils/api'; +import { + appErrorToErrorStack, + convertErrorToEnumerable, + errorToErrorStack, + errorToErrorStackAdapter, + esErrorToErrorStack, + getStringifiedStack, + isEmptyObjectWhenStringified, + MaybeESError, + unknownToErrorStack, + useAppToasts, +} from './use_app_toasts'; jest.mock('../lib/kibana'); @@ -29,45 +41,449 @@ describe('useAppToasts', () => { })); }); - it('works normally with a regular error', async () => { - const error = new Error('regular error'); - const { result } = renderHook(() => useAppToasts()); + describe('useAppToasts', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const { result } = renderHook(() => useAppToasts()); - result.current.addError(error, { title: 'title' }); + result.current.addError(error, { title: 'title' }); - expect(addErrorMock).toHaveBeenCalledWith(error, { title: 'title' }); + expect(addErrorMock).toHaveBeenCalledWith(error, { title: 'title' }); + }); + + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(unknownError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error(`${undefined}`), { + title: 'title', + }); + }); + + it("uses a AppError's body.message as the toastMessage", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + + expect(addErrorMock).toHaveBeenCalledWith(Error('Detailed Message (404)'), { + title: 'title', + }); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(kibanaApiError, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual(''); + expect(JSON.parse(errorObj.stack)).toEqual({ + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }); + }); + + it('works normally with a bsearch type error', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const { result } = renderHook(() => useAppToasts()); + + result.current.addError(error, { title: 'title' }); + const expected = Error('some message (400)'); + expect(addErrorMock).toHaveBeenCalledWith(expected, { title: 'title' }); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const { result } = renderHook(() => useAppToasts()); + result.current.addError(error, { title: 'title' }); + const errorObj = addErrorMock.mock.calls[0][0]; + expect(errorObj.name).toEqual('some message'); + expect(JSON.parse(errorObj.stack)).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); + }); }); - it('converts an unknown error to an Error', () => { - const unknownError = undefined; + describe('errorToErrorStackAdapter', () => { + it('works normally with a regular error', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(error); + }); - const { result } = renderHook(() => useAppToasts()); + it('has a stack on the error with name, message, and a stack call', async () => { + const error = new Error('regular error'); + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.name).toEqual('Error'); + expect(parsedStack.message).toEqual('regular error'); + expect(parsedStack.stack).toEqual(expect.stringContaining('Error: regular error')); + }); - result.current.addError(unknownError, { title: 'title' }); + it('converts an unknown error to an Error', () => { + const unknownError = undefined; + const result = errorToErrorStackAdapter(unknownError); + expect(result).toEqual(Error('undefined')); + }); - expect(addErrorMock).toHaveBeenCalledWith(Error(`${undefined}`), { - title: 'title', + it("uses a AppError's body.message", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + expect(result).toEqual(Error('Detailed Message (404)')); + }); + + it("parses AppError's body in the stack trace", async () => { + const kibanaApiError = { + message: 'Not Found', + body: { status_code: 404, message: 'Detailed Message' }, + }; + const result = errorToErrorStackAdapter(kibanaApiError); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack.message).toEqual('Not Found'); + expect(parsedStack.body).toEqual({ status_code: 404, message: 'Detailed Message' }); + }); + + it('works normally with a bsearch type error', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const result = errorToErrorStackAdapter(error); + expect(result).toEqual(Error('some message (400)')); + }); + + it('parses a bsearch correctly in the stack and name', async () => { + const error = ({ + message: 'some message', + attributes: {}, // empty object and should not show up in the output + err: { + statusCode: 400, + innerMessages: { somethingElse: 'message' }, + }, + } as unknown) as IEsError; + const result = errorToErrorStackAdapter(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 400, + innerMessages: { + somethingElse: 'message', + }, + }); }); }); - it('works normally with a bsearch type error', async () => { - const error = ({ - message: 'some message', - attributes: {}, - err: { + describe('esErrorToErrorStack', () => { + it('works with a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a IEsError that is not an EsError', async () => { + const error: IEsError = { + statusCode: 200, + message: 'a message', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ statusCode: 200, message: 'a message' }); + }); + + it('prefers the attributes reason if we have it for the message', async () => { + const error: IEsError = { + attributes: { type: 'some type', reason: 'message we want' }, + statusCode: 200, + message: 'message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('works with an EsError, by using the inner error and not outer error if available', async () => { + const error: MaybeESError = { + attributes: { type: 'some type', reason: 'message we want' }, + statusCode: 400, + err: { + statusCode: 200, + attributes: { reason: 'attribute message we do not want' }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + expect(result).toEqual(Error('message we want (200)')); + }); + + it('creates a stack trace of a EsError and not the outer object', async () => { + const error: MaybeESError = { + attributes: { type: 'some type', reason: 'message we do not want' }, statusCode: 400, - innerMessages: { somethingElse: 'message' }, - }, - } as unknown) as IEsError; - const { result } = renderHook(() => useAppToasts()); - - result.current.addError(error, { title: 'title' }); - const errorObj = addErrorMock.mock.calls[0][0]; - expect(errorObj).toEqual({ - message: 'some message (400)', - name: 'some message', - stack: - '{\n "statusCode": 400,\n "innerMessages": {\n "somethingElse": "message"\n }\n}', + err: { + statusCode: 200, + attributes: { reason: 'attribute message we do want' }, + }, + message: 'main message we do not want', + }; + const result = esErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + statusCode: 200, + attributes: { reason: 'attribute message we do want' }, + }); + }); + }); + + describe('appErrorToErrorStack', () => { + it('works with a AppError that is a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a KibanaError', async () => { + const error: KibanaError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + statusCode: 200, + }, + }); + }); + + it('works with a AppError that is a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + expect(result).toEqual(Error('a message (200)')); + }); + + it('creates a stack trace of a SecurityAppError', async () => { + const error: SecurityAppError = { + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }; + const result = appErrorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + body: { + message: 'a message', + status_code: 200, + }, + }); + }); + }); + + describe('errorToErrorStack', () => { + it('works with an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + expect(result).toEqual(Error('message')); + }); + + it('creates a stack trace of an Error', async () => { + const error: Error = { + message: 'message', + name: 'some name', + }; + const result = errorToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual({ + message: 'message', + name: 'some name', + }); + }); + }); + + describe('unknownToErrorStack', () => { + it('works with a string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error('error')); + }); + + it('works with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('works with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + expect(result).toEqual(Error(JSON.stringify(error, null, 2))); + }); + + it('does create a stack error from a plain string of that string', async () => { + const error = 'error'; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an object that has fields by using a stringification of it', async () => { + const error = { a: 1, b: 1 }; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + + it('does create a stack with an an array that has fields by using a stringification of it', async () => { + const error = [{ a: 1, b: 1 }]; + const result = unknownToErrorStack(error); + const parsedStack = JSON.parse(result.stack ?? ''); + expect(parsedStack).toEqual(error); + }); + }); + + describe('getStringifiedStack', () => { + it('works with an Error object', async () => { + const result = getStringifiedStack(new Error('message')); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult.name).toEqual('Error'); + expect(parsedResult.message).toEqual('message'); + expect(parsedResult.stack).toEqual(expect.stringContaining('Error: message')); + }); + + it('works with a regular object', async () => { + const regularObject = { a: 'regular object' }; + const result = getStringifiedStack(regularObject); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(regularObject); + }); + + it('returns undefined with a circular reference', async () => { + const circleRef = { a: {} }; + circleRef.a = circleRef; + const result = getStringifiedStack(circleRef); + expect(result).toEqual(undefined); + }); + + it('returns undefined if given an empty object', async () => { + const emptyObj = {}; + const result = getStringifiedStack(emptyObj); + expect(result).toEqual(undefined); + }); + + it('returns a string if given a string', async () => { + const stringValue = 'some value'; + const result = getStringifiedStack(stringValue); + expect(result).toEqual(`"${stringValue}"`); + }); + + it('returns an array if given an array', async () => { + const value = ['some value']; + const result = getStringifiedStack(value); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual(value); + }); + + it('removes top level empty objects if found to clean things up a bit', async () => { + const objectWithEmpties = { a: {}, b: { c: 1 }, d: {}, e: {} }; + const result = getStringifiedStack(objectWithEmpties); + const parsedResult = JSON.parse(result ?? ''); + expect(parsedResult).toEqual({ b: { c: 1 } }); + }); + }); + + describe('convertErrorToEnumerable', () => { + test('it will return a stringable Error object', () => { + const converted = convertErrorToEnumerable(new Error('message')); + // delete the stack off the converted for testing determinism + delete (converted as Error).stack; + expect(JSON.stringify(converted)).toEqual( + JSON.stringify({ name: 'Error', message: 'message' }) + ); + }); + + test('it will return a value not touched if it is not an error instances', () => { + const obj = { a: 1 }; + const converted = convertErrorToEnumerable(obj); + expect(converted).toBe(obj); + }); + }); + + describe('isEmptyObjectWhenStringified', () => { + test('it returns false when handed a non-object', () => { + expect(isEmptyObjectWhenStringified('string')).toEqual(false); + }); + + test('it returns false when handed a non-empty object', () => { + expect(isEmptyObjectWhenStringified({ a: 1 })).toEqual(false); + }); + + test('it returns true when handed an empty object', () => { + expect(isEmptyObjectWhenStringified({})).toEqual(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index f5a3c75747e52..61b20e137f870 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -6,11 +6,12 @@ */ import { useCallback, useRef } from 'react'; +import { isString } from 'lodash/fp'; import { IEsError, isEsError } from '../../../../../../src/plugins/data/public'; import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/core/public'; import { useToasts } from '../lib/kibana'; -import { isAppError } from '../utils/api'; +import { AppError, isAppError, isKibanaError, isSecurityAppError } from '../utils/api'; export type UseAppToasts = Pick & { api: ToastsStart; @@ -32,32 +33,40 @@ export const useAppToasts = (): UseAppToasts => { const _addError = useCallback( (error: unknown, options: ErrorToastOptions) => { - if (error != null && isEsError(error)) { - const err = esErrorToRequestError(error); - return addError(err, options); - } else if (isAppError(error)) { - return addError(error, options); - } else if (error instanceof Error) { - return addError(error, options); - } else { - // Best guess that this is a stringable error. - const err = new Error(String(error)); - return addError(err, options); - } + const adaptedError = errorToErrorStackAdapter(error); + return addError(adaptedError, options); }, [addError] ); - return { api: toasts, addError: _addError, addSuccess, addWarning }; }; +/** + * Given an error of one type vs. another type this tries to adapt + * the best it can to the existing error toaster which parses the .stack + * as its error when you click the button to show the full error message. + * @param error The error to adapt to. + * @returns The adapted toaster error message. + */ +export const errorToErrorStackAdapter = (error: unknown): Error => { + if (error != null && isEsError(error)) { + return esErrorToErrorStack(error); + } else if (isAppError(error)) { + return appErrorToErrorStack(error); + } else if (error instanceof Error) { + return errorToErrorStack(error); + } else { + return unknownToErrorStack(error); + } +}; + /** * See this file, we are not allowed to import files such as es_error. * So instead we say maybe err is on there so that we can unwrap it and get * our status code from it if possible within the error in our function. * src/plugins/data/public/search/errors/es_error.tsx */ -type MaybeESError = IEsError & { err?: Record }; +export type MaybeESError = IEsError & { err?: Record }; /** * This attempts its best to map between an IEsError which comes from bsearch to a error_toaster @@ -72,13 +81,152 @@ type MaybeESError = IEsError & { err?: Record }; * * Where this same technique of overriding and changing the stack is occurring. */ -export const esErrorToRequestError = (error: IEsError & MaybeESError): Error => { +export const esErrorToErrorStack = (error: IEsError & MaybeESError): Error => { const maybeUnWrapped = error.err != null ? error.err : error; - const statusCode = error.err?.statusCode != null ? `(${error.err.statusCode})` : ''; - const stringifiedError = JSON.stringify(maybeUnWrapped, null, 2); - return { - message: `${error.attributes?.reason ?? error.message} ${statusCode}`, - name: error.attributes?.reason ?? error.message, - stack: stringifiedError, - }; + const statusCode = + error.err?.statusCode != null + ? `(${error.err.statusCode})` + : error.statusCode != null + ? `(${error.statusCode})` + : ''; + const stringifiedError = getStringifiedStack(maybeUnWrapped); + const adaptedError = new Error(`${error.attributes?.reason ?? error.message} ${statusCode}`); + adaptedError.name = error.attributes?.reason ?? error.message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * This attempts its best to map between a Kibana application error which can come from backend + * REST API's that are typically of a particular format and form. + * + * The existing error_toaster code tries to consolidate network and software stack traces but really + * here and our toasters we are using them for network response errors so we can troubleshoot things + * as quick as possible. + * + * We override and use error.stack to be able to give _full_ network responses regardless of if they + * are from Kibana or if they are from elasticSearch since sometimes Kibana errors might wrap the errors. + * + * Sometimes the errors are wrapped from io-ts, Kibana Schema or something else and we want to show + * as full error messages as we can. + */ +export const appErrorToErrorStack = (error: AppError): Error => { + const statusCode = isKibanaError(error) + ? `(${error.body.statusCode})` + : isSecurityAppError(error) + ? `(${error.body.status_code})` + : ''; + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error( + `${String(error.body.message).trim() !== '' ? error.body.message : error.message} ${statusCode}` + ); + // Note although all the Typescript typings say that error.name is a string and exists, we still can encounter an undefined so we + // do an extra guard here and default to empty string if it is undefined + adaptedError.name = error.name != null ? error.name : ''; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Takes an error and tries to stringify it and use that as the stack for the error toaster + * @param error The error to convert into a message + * @returns The exception error to return back + */ +export const errorToErrorStack = (error: Error): Error => { + const stringifiedError = getStringifiedStack(error); + const adaptedError = new Error(error.message); + adaptedError.name = error.name; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Last ditch effort to take something unknown which could be a string, number, + * anything. This usually should not be called but just in case we do try our + * best to stringify it and give a message, name, and replace the stack of it. + * @param error The unknown error to convert into a message + * @returns The exception error to return back + */ +export const unknownToErrorStack = (error: unknown): Error => { + const stringifiedError = getStringifiedStack(error); + const message = isString(error) + ? error + : error instanceof Object && stringifiedError != null + ? stringifiedError + : String(error); + const adaptedError = new Error(message); + adaptedError.name = message; + if (stringifiedError != null) { + adaptedError.stack = stringifiedError; + } + return adaptedError; +}; + +/** + * Stringifies the error. However, since Errors can JSON.stringify into empty objects this will + * use a replacer to push those as enumerable properties so we can stringify them. + * @param error The error to get a string representation of + * @returns The string representation of the error + */ +export const getStringifiedStack = (error: unknown): string | undefined => { + try { + return JSON.stringify( + error, + (_, value) => { + const enumerable = convertErrorToEnumerable(value); + if (isEmptyObjectWhenStringified(enumerable)) { + return undefined; + } else { + return enumerable; + } + }, + 2 + ); + } catch (err) { + return undefined; + } +}; + +/** + * Converts an error if this is an error to have enumerable so it can stringified + * @param error The error which might not have enumerable properties. + * @returns Enumerable error + */ +export const convertErrorToEnumerable = (error: unknown): unknown => { + if (error instanceof Error) { + return { + ...error, + name: error.name, + message: error.message, + stack: error.stack, + }; + } else { + return error; + } +}; + +/** + * If the object strings into an empty object we shouldn't show it as it doesn't + * add value and sometimes different people/frameworks attach req,res,request,response + * objects which don't stringify into anything or can have circular references. + * @param item The item to see if we are empty or have a circular reference error with. + * @returns True if this is a good object to stringify, otherwise false + */ +export const isEmptyObjectWhenStringified = (item: unknown): boolean => { + if (item instanceof Object) { + try { + return JSON.stringify(item) === '{}'; + } catch (_) { + // Do nothing, return false if we have a circular reference or other oddness. + return false; + } + } else { + return false; + } };