diff --git a/.changeset/wet-socks-grow.md b/.changeset/wet-socks-grow.md new file mode 100644 index 0000000000..8ccbb7a2a2 --- /dev/null +++ b/.changeset/wet-socks-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage/errors': patch +--- + +Add `stringifyError` that is useful for logging e.g. `Something went wrong, ${stringifyError(e)}` diff --git a/packages/errors/api-report.md b/packages/errors/api-report.md index 6552e111ce..ffb111884e 100644 --- a/packages/errors/api-report.md +++ b/packages/errors/api-report.md @@ -18,7 +18,7 @@ export class ConflictError extends CustomErrorBase {} export class CustomErrorBase extends Error { constructor(message?: string, cause?: Error | unknown); // (undocumented) - readonly cause?: Error; + readonly cause?: Error | undefined; } // @public @@ -98,4 +98,7 @@ export function serializeError( includeStack?: boolean; }, ): SerializedError; + +// @public +export function stringifyError(error: unknown): string; ``` diff --git a/packages/errors/src/errors/CustomErrorBase.ts b/packages/errors/src/errors/CustomErrorBase.ts index 5c5c032de2..17149cd398 100644 --- a/packages/errors/src/errors/CustomErrorBase.ts +++ b/packages/errors/src/errors/CustomErrorBase.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { stringifyError } from '../serialization'; import { isError } from './assertion'; /** @@ -32,25 +33,12 @@ import { isError } from './assertion'; * ``` */ export class CustomErrorBase extends Error { - readonly cause?: Error; + readonly cause?: Error | undefined; constructor(message?: string, cause?: Error | unknown) { - let assignedCause: Error | undefined = undefined; - let fullMessage = message; if (cause !== undefined) { - let causeStr; - if (isError(cause)) { - assignedCause = cause; - causeStr = String(cause); - - // Prefer the cause.toString, but if it's not implemented we use a nicer fallback - if (causeStr === '[object Object]') { - causeStr = `${cause.name}: ${cause.message}`; - } - } else { - causeStr = `unknown error '${cause}'`; - } + const causeStr = stringifyError(cause); if (fullMessage) { fullMessage += `; caused by ${causeStr}`; } else { @@ -63,6 +51,6 @@ export class CustomErrorBase extends Error { Error.captureStackTrace?.(this, this.constructor); this.name = this.constructor.name; - this.cause = assignedCause; + this.cause = isError(cause) ? cause : undefined; } } diff --git a/packages/errors/src/serialization/error.test.ts b/packages/errors/src/serialization/error.test.ts index 27691e03f2..a07d11749a 100644 --- a/packages/errors/src/serialization/error.test.ts +++ b/packages/errors/src/serialization/error.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { deserializeError, serializeError } from './error'; +import { NotModifiedError } from '../errors'; +import { deserializeError, serializeError, stringifyError } from './error'; class CustomError extends Error { readonly customField: any; @@ -61,4 +62,30 @@ describe('serialization', () => { expect(withoutStack1.stack).not.toBeDefined(); expect(withoutStack2.stack).not.toBeDefined(); }); + + it('stringifies all supported forms', () => { + expect(stringifyError({})).toEqual("unknown error '[object Object]'"); + expect( + stringifyError({ + toString() { + return 'str'; + }, + }), + ).toEqual("unknown error 'str'"); + expect( + stringifyError({ + name: 'not used', + message: 'not used', + toString() { + return 'str'; + }, + }), + ).toEqual('str'); + expect(stringifyError({ name: 'N', message: 'm1' })).toEqual('N: m1'); + expect(stringifyError(new NotModifiedError('m2'))).toEqual( + 'NotModifiedError: m2', + ); + expect(stringifyError(new Error('m3'))).toEqual('Error: m3'); + expect(stringifyError(new TypeError('m4'))).toEqual('TypeError: m4'); + }); }); diff --git a/packages/errors/src/serialization/error.ts b/packages/errors/src/serialization/error.ts index ab543164d8..b1f63063cf 100644 --- a/packages/errors/src/serialization/error.ts +++ b/packages/errors/src/serialization/error.ts @@ -19,6 +19,7 @@ import { deserializeError as deserializeErrorInternal, serializeError as serializeErrorInternal, } from 'serialize-error'; +import { isError } from '../errors'; /** * The serialized form of an Error. @@ -78,3 +79,19 @@ export function deserializeError( } return result; } + +/** + * Stringifies an error, including its name and message where available. + * + * @param error - The error. + * @public + */ +export function stringifyError(error: unknown): string { + if (isError(error)) { + // Prefer error.toString, but if it's not implemented we use a nicer fallback + const str = String(error); + return str !== '[object Object]' ? str : `${error.name}: ${error.message}`; + } + + return `unknown error '${error}'`; +} diff --git a/packages/errors/src/serialization/index.ts b/packages/errors/src/serialization/index.ts index a4ade6df95..d37b04297a 100644 --- a/packages/errors/src/serialization/index.ts +++ b/packages/errors/src/serialization/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { deserializeError, serializeError } from './error'; +export { deserializeError, serializeError, stringifyError } from './error'; export type { SerializedError } from './error'; export { parseErrorResponse } from './response'; export type { ErrorResponse } from './response'; diff --git a/plugins/catalog-backend/src/stitching/Stitcher.ts b/plugins/catalog-backend/src/stitching/Stitcher.ts index 1d40340b3e..507f3aa53e 100644 --- a/plugins/catalog-backend/src/stitching/Stitcher.ts +++ b/plugins/catalog-backend/src/stitching/Stitcher.ts @@ -20,7 +20,7 @@ import { parseEntityRef, UNSTABLE_EntityStatusItem, } from '@backstage/catalog-model'; -import { SerializedError } from '@backstage/errors'; +import { SerializedError, stringifyError } from '@backstage/errors'; import { Knex } from 'knex'; import { uniqBy } from 'lodash'; import { v4 as uuid } from 'uuid'; @@ -49,7 +49,9 @@ export class Stitcher { try { await this.stitchOne(entityRef); } catch (error) { - this.logger.error(`Failed to stitch ${entityRef}, ${error}`); + this.logger.error( + `Failed to stitch ${entityRef}, ${stringifyError(error)}`, + ); } } }