diff --git a/README.md b/README.md index 6aef271..32f1fc7 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,22 @@ A set of [winston](https://github.com/winstonjs/winston) [formats](https://githu npm install @makerx/node-winston ``` -`winston`, `logform`, `winston-transport`, `triple-beam`, `es-toolkit` and `serialize-error` are declared as peer dependencies, so bring your own versions (>=3, >=2, >=4, >=1, >=1, >=13 respectively). +`winston`, `logform`, `winston-transport`, `triple-beam` and `es-toolkit` are declared as peer dependencies, so bring your own versions (>=3, >=2, >=4, >=1, >=1 respectively). -Requires Node.js `>=22.12` (for flag-free `require(esm)` support, needed by `serialize-error`'s ESM-only publish). +Requires Node.js `>=20`. ## Migrating from v1 Breaking changes: - The `lodash` dependency has been replaced with `es-toolkit` as a peer dependency. -- Node.js `>=22.12` is required (for flag-free `require(esm)`, needed by `serialize-error`'s ESM-only publish). -- **Error serialisation has changed.** `serialize-error` is now a peer dependency, and `serializeError` delegates to it instead of v1's hand-rolled `{ message, stack, ...rest }` shape. The serialised wire format now includes `name`, follows `cause` chains, walks own enumerable properties, and handles circular references — so any consumer asserting against the exact v1 shape will need to update. +- Node.js `>=20` is required. +- **Error serialisation has changed.** `serializeError` now captures `name` (in addition to `message` and `stack`), follows `cause` chains, walks own enumerable properties, captures `AggregateError.errors`, stringifies `BigInt` values, replaces `Buffer` and stream values with sentinel strings, and is safe against circular references. Any consumer asserting against v1's exact `{ message, stack, ...rest }` shape will need to update. - **Errors nested in structured metadata are now serialised on every transport, not just the Console transport.** v1 only ran `serializableErrorReplacer` inside the Console's `format.json`, so custom transports received the raw `Error` instance (with non-enumerable `message`/`stack` hidden). v2 prepends the new `serializeErrorFormat` at the logger level, which walks the full info tree and substitutes plain objects before any transport sees them. If a custom transport relied on receiving an `Error` instance, switch it to read the serialised shape — or pass `errorSerializer` to `createLogger` (or `serializer` to `serializeErrorFormat` directly) to plug in your own transformation. - `omitPaths` now applies at the logger level and affects every transport, not just the Console transport. If you added custom transports expecting the un-omitted object, move omit handling into that transport's format. - A new `audit` level sits between `warn` and `info`. Loggers configured at `level: 'info'` (or more verbose) will now include `audit` messages; loggers at `level: 'warn'` or higher still filter them out. - Pass a custom `levels` map via `loggerOptions` to opt out of the default level set (including `audit`); the returned logger type narrows to your keys. -- Submodule deep-imports (e.g. `@makerx/node-winston/redact-format`, `@makerx/node-winston/serialize-error`) are no longer exported. The package's `exports` field declares a single `.` entry; every public format, helper and type is re-exported from the root, so import them from `@makerx/node-winston` directly. The `./serialize-error` subpath in particular has no replacement: `serializeError` is still re-exported from the root, but for direct use of the underlying serializer, import from the [`serialize-error`](https://www.npmjs.com/package/serialize-error) peer dependency — we no longer wrap it in a dedicated subpath. +- Submodule deep-imports (e.g. `@makerx/node-winston/redact-format`, `@makerx/node-winston/serialize-error`) are no longer exported. The package's `exports` field declares a single `.` entry; every public format, helper and type is re-exported from the root, so import them from `@makerx/node-winston` directly. New functionality: @@ -47,20 +47,20 @@ Formats are applied in two layers: ### Options -| Option | Type | Description | -| ---------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | -| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | -| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | -| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | -| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | -| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. Applied at the logger level, so affects all transports. | -| `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | -| `flatten` | `boolean` | When `true`, serialises every top-level value on the log info to a JSON string, producing a flat `{ key: string }` shape for transports that expect scalar values (e.g. OTEL + Azure Log Analytics). | -| `flattenReplacer` | `(key, value) => any` | Optional `JSON.stringify` replacer used when `flatten` serialises each top-level value. | -| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which delegates to [`serialize-error`](https://www.npmjs.com/package/serialize-error). | -| `mapAuditLevelForOtel` | `boolean` | When `true`, rewrites the triple-beam `LEVEL` from `audit` to `info` and copies the original onto `logLevel` for OTEL compatibility. See [Shipping the audit level via OpenTelemetry](#shipping-the-audit-level-via-opentelemetry). | -| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `flatten` when enabled. | +| Option | Type | Description | +| ---------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `consoleFormat` | `'json' \| 'pretty'` | Output format for the Console transport. `json` (default) for deployed environments, `pretty` for colourised YAML during local development. | +| `consoleOptions` | `ConsoleTransportOptions` | Options forwarded to the Console transport (e.g. `silent`, per-transport `level`). The `format` property is managed by this library. | +| `consoleFormats` | `Format[]` | Extra formats appended to the Console transport's format chain, before the final `json`/`pretty` step. Applies to the Console transport only. | +| `transports` | `Transport[]` | Additional winston transports attached alongside the Console transport. | +| `omitPaths` | `string[]` | Dot-notation paths to remove from every log entry. Applied at the logger level, so affects all transports. | +| `redactPaths` | `string[]` | Dot-notation paths whose values are replaced with `redactedValue`. Applied at the logger level, so affects all transports. | +| `redactedValue` | `string` | Replacement value used by `redactPaths`. Defaults to `''`. | +| `flatten` | `boolean` | When `true`, serialises every top-level value on the log info to a JSON string, producing a flat `{ key: string }` shape for transports that expect scalar values (e.g. OTEL + Azure Log Analytics). | +| `flattenReplacer` | `(key, value) => any` | Optional `JSON.stringify` replacer used when `flatten` serialises each top-level value. | +| `errorSerializer` | `ErrorSerializer` | Custom serializer applied to every `Error` instance at the logger level (via `serializeErrorFormat`) and as the Console transport's `format.json` replacer. Defaults to the library's `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when non-enumerable, walks own enumerable properties, and is safe against circular references. | +| `mapAuditLevelForOtel` | `boolean` | When `true`, rewrites the triple-beam `LEVEL` from `audit` to `info` and copies the original onto `logLevel` for OTEL compatibility. See [Shipping the audit level via OpenTelemetry](#shipping-the-audit-level-via-opentelemetry). | +| `loggerOptions` | `LoggerOptions` | Winston logger options (e.g. `level`, `defaultMeta`). A `format` supplied here is appended after the library's logger-level formats but still runs before `flatten` when enabled. | ### Log levels @@ -309,7 +309,7 @@ try { `createLogger` solves this with two complementary mechanisms: -- `serializeErrorFormat` runs at the logger level and walks the log info, replacing any `Error` instance (at any depth) with a plain, JSON-serializable object (via the [`serialize-error`](https://www.npmjs.com/package/serialize-error) package). This applies to every transport. +- `serializeErrorFormat` runs at the logger level and walks the log info, replacing any `Error` instance (at any depth) with a plain, JSON-serializable object via the library's `serializeError`. This applies to every transport. - `serializableErrorReplacer` is passed to the Console transport's final `format.json()` as a safety net — [logform](https://github.com/winstonjs/logform) uses [safe-stable-stringify](https://www.npmjs.com/package/safe-stable-stringify), which accepts a replacer, so any `Error` that slips through is still serialised correctly. ```ts diff --git a/package-lock.json b/package-lock.json index 88a8a75..f61c60a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,6 @@ "npm-run-all": "^4.1.5", "rimraf": "^6.1.3", "rollup": "4.60.2", - "serialize-error": "^13.0.1", "tslib": "^2.8.1", "tsx": "4.21.0", "typescript": "^6.0.2", @@ -50,7 +49,6 @@ "peerDependencies": { "es-toolkit": ">=1", "logform": ">=2", - "serialize-error": ">=13", "triple-beam": ">=1", "winston": ">=3", "winston-transport": ">=4" @@ -5617,19 +5615,6 @@ "readable-stream": "~1.0.31" } }, - "node_modules/non-error": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz", - "integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -6661,23 +6646,6 @@ "node": ">=10" } }, - "node_modules/serialize-error": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz", - "integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "non-error": "^0.1.0", - "type-fest": "^5.4.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7197,19 +7165,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -7410,22 +7365,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", diff --git a/package.json b/package.json index 1b2c63b..1a397bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/node-winston", - "version": "2.0.0-beta.8", + "version": "2.0.0-beta.9", "private": false, "description": "A set of winston formats, console transport and logger creation functions", "author": "MakerX", @@ -10,7 +10,7 @@ "types": "index.d.ts", "main": "index.cjs", "engines": { - "node": ">=22.12" + "node": ">=20" }, "bugs": { "url": "https://github.com/MakerXStudio/node-winston/issues" @@ -34,6 +34,7 @@ "build:4-copy-pkg-json": "tstk copy-package-json -c", "build:5-copy-readme": "copyfiles ./README.md ./dist", "build:6-check-exports": "attw --pack dist", + "build:7-smoke-cjs": "node ./scripts/smoke-cjs.cjs", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:ci": "vitest run --coverage --reporter junit --outputFile test-results.xml" @@ -63,7 +64,6 @@ "npm-run-all": "^4.1.5", "rimraf": "^6.1.3", "rollup": "4.60.2", - "serialize-error": "^13.0.1", "tslib": "^2.8.1", "tsx": "4.21.0", "typescript": "^6.0.2", @@ -72,7 +72,6 @@ "peerDependencies": { "es-toolkit": ">=1", "logform": ">=2", - "serialize-error": ">=13", "triple-beam": ">=1", "winston": ">=3", "winston-transport": ">=4" diff --git a/scripts/smoke-cjs.cjs b/scripts/smoke-cjs.cjs new file mode 100644 index 0000000..86f8911 --- /dev/null +++ b/scripts/smoke-cjs.cjs @@ -0,0 +1,40 @@ +// Verifies the built CJS bundle in `dist/` is consumable from a CommonJS context. +// Guards specifically against `require('serialize-error')` (ESM-only) regressions: +// the bundle relies on Node's flag-free `require(esm)` interop (>=22.12). + +const path = require('node:path') + +const distDir = path.resolve(__dirname, '..', 'dist') +const distPkg = require(path.join(distDir, 'package.json')) +const main = require(path.join(distDir, distPkg.main)) + +const expectedFns = [ + 'serializeError', + 'createSerializableErrorReplacer', + 'serializableErrorReplacer', + 'serializeErrorFormat', + 'createLogger', + 'jsonStringifyValuesFormat', + 'redactFormat', + 'omitFormat', + 'omitNilFormat', + 'prettyConsoleFormat', +] +const missing = expectedFns.filter((name) => typeof main[name] !== 'function') +if (missing.length > 0) { + console.error(`CJS smoke: missing or non-function exports: ${missing.join(', ')}`) + process.exit(1) +} + +const err = new Error('boom') +err.cause = new Error('cause') +const out = main.serializeError(err) +if (!out || out.message !== 'boom' || !out.cause || out.cause.message !== 'cause') { + console.error('CJS smoke: serializeError output unexpected:', out) + process.exit(1) +} + +const logger = main.createLogger({ name: 'smoke', consoleOptions: { silent: true } }) +logger.info('cjs smoke ok') + +console.log('CJS smoke test passed') diff --git a/src/index.ts b/src/index.ts index 47f21da..d358777 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,8 +180,8 @@ export interface CreateLoggerOptions { * Custom serializer used whenever an `Error` instance is encountered, both by the logger-level * `serializeErrorFormat` (walks the full info tree) and by the Console transport's * `format.json` replacer (safety net for errors that slip through). Defaults to the library's - * `serializeError`, which delegates to the - * [`serialize-error`](https://www.npmjs.com/package/serialize-error) package. + * `serializeError`, which captures `name`/`message`/`stack`/`code`/`cause`/`errors` even when + * non-enumerable, walks own enumerable properties, and is safe against circular references. */ errorSerializer?: ErrorSerializer diff --git a/src/serialize-error-format.ts b/src/serialize-error-format.ts index 46e2033..df7cdee 100644 --- a/src/serialize-error-format.ts +++ b/src/serialize-error-format.ts @@ -5,8 +5,7 @@ import { ErrorSerializer, serializeError } from './serialize-error' export interface SerializeErrorFormatOptions { /** * Custom serializer used to turn each `Error` instance into a plain object. - * Defaults to the library's {@link serializeError} (which delegates to the - * [`serialize-error`](https://www.npmjs.com/package/serialize-error) package). + * Defaults to the library's {@link serializeError}. */ serializer?: ErrorSerializer } diff --git a/src/serialize-error.spec.ts b/src/serialize-error.spec.ts index b184719..fec6456 100644 --- a/src/serialize-error.spec.ts +++ b/src/serialize-error.spec.ts @@ -1,3 +1,4 @@ +import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' import { createSerializableErrorReplacer, serializableErrorReplacer, serializeError } from './serialize-error' @@ -7,56 +8,161 @@ describe('serializeError', () => { expect(message).toBeUndefined() expect(stack).toBeUndefined() }) - it('can serialize error message and stack props', () => { - const { message, stack } = JSON.parse(JSON.stringify(serializeError(new Error('message')))) as { message: string; stack: string } - expect(message).toMatchInlineSnapshot(`"message"`) - expect(stack).toBeDefined() - expect(stack.split('\n').length).toBeGreaterThan(3) + + it('captures non-enumerable name, message, and stack on a plain Error', () => { + const out = JSON.parse(JSON.stringify(serializeError(new Error('boom')))) as { + name: string + message: string + stack: string + } + expect(out.name).toBe('Error') + expect(out.message).toBe('boom') + expect(out.stack).toBeDefined() + expect(out.stack.split('\n').length).toBeGreaterThan(3) }) - it('can serialize a custom error with a cause', () => { + + it('preserves a custom subclass name and own enumerable properties', () => { class CustomError extends Error { readonly custom: string constructor(message: string, custom: string, options?: ErrorOptions) { super(message, options) + this.name = 'CustomError' this.custom = custom } } const cause = new Error('cause message') - const { - message, - stack, - custom, - cause: serializedCause, - } = JSON.parse(JSON.stringify(serializeError(new CustomError('message', 'custom', { cause })))) as { + const out = JSON.parse(JSON.stringify(serializeError(new CustomError('message', 'custom', { cause })))) as { + name: string message: string stack: string custom: string cause: { name: string; message: string; stack: string } } - expect(message).toMatchInlineSnapshot(`"message"`) - expect(custom).toMatchInlineSnapshot(`"custom"`) - expect(stack).toBeDefined() - expect(stack.split('\n').length).toBeGreaterThan(3) - expect(serializedCause.name).toMatchInlineSnapshot(`"Error"`) - expect(serializedCause.message).toMatchInlineSnapshot(`"cause message"`) - expect(serializedCause.stack).toBeDefined() - expect(serializedCause.stack.split('\n').length).toBeGreaterThan(3) + expect(out.name).toBe('CustomError') + expect(out.message).toBe('message') + expect(out.custom).toBe('custom') + expect(out.stack.split('\n').length).toBeGreaterThan(3) + expect(out.cause.name).toBe('Error') + expect(out.cause.message).toBe('cause message') + expect(out.cause.stack.split('\n').length).toBeGreaterThan(3) + }) + + it('walks a multi-level cause chain', () => { + const root = new Error('root') + const middle = new Error('middle', { cause: root }) + const top = new Error('top', { cause: middle }) + const out = serializeError(top) as { message: string; cause: { message: string; cause: { message: string } } } + expect(out.message).toBe('top') + expect(out.cause.message).toBe('middle') + expect(out.cause.cause.message).toBe('root') + }) + + it('captures AggregateError.errors with each entry serialised', () => { + const aggregate = new AggregateError([new Error('first'), new Error('second')], 'multiple failures') + const out = JSON.parse(JSON.stringify(serializeError(aggregate))) as { + name: string + message: string + errors: { name: string; message: string }[] + } + expect(out.name).toBe('AggregateError') + expect(out.message).toBe('multiple failures') + expect(out.errors).toHaveLength(2) + expect(out.errors[0].message).toBe('first') + expect(out.errors[1].message).toBe('second') + }) + + it('renders cycles as [Circular] without throwing', () => { + const a = new Error('a') as Error & { cause?: unknown } + const b = new Error('b') as Error & { cause?: unknown } + a.cause = b + b.cause = a + const out = serializeError(a) as { message: string; cause: { message: string; cause: string } } + expect(out.message).toBe('a') + expect(out.cause.message).toBe('b') + expect(out.cause.cause).toBe('[Circular]') + }) + + it('renders self-referential errors as [Circular]', () => { + const e = new Error('self') as Error & { self?: unknown } + e.self = e + const out = serializeError(e) as { message: string; self: string } + expect(out.message).toBe('self') + expect(out.self).toBe('[Circular]') + }) + + it('wraps a non-Error string throw as a NonError', () => { + const out = serializeError('just a string' as unknown as Error) + expect(out).toEqual({ name: 'NonError', message: 'just a string' }) + }) + + it('wraps null and undefined as NonError without throwing', () => { + expect(serializeError(null as unknown as Error)).toEqual({ name: 'NonError', message: 'null' }) + expect(serializeError(undefined as unknown as Error)).toEqual({ name: 'NonError', message: 'undefined' }) + }) + + it('serialises BigInt properties as suffixed strings', () => { + const e = Object.assign(new Error('big'), { count: 9007199254740993n }) + const out = serializeError(e) as { count: string } + expect(out.count).toBe('9007199254740993n') + expect(JSON.stringify(out)).toContain('"count":"9007199254740993n"') + }) + + it('renders Buffer-typed properties as a sentinel string', () => { + const e = Object.assign(new Error('buf'), { body: Buffer.from('hello') }) + const out = serializeError(e) as { body: string } + expect(out.body).toBe('[object Buffer]') + }) + + it('renders stream-like properties as a sentinel string', () => { + const e = Object.assign(new Error('stream'), { source: Readable.from(['chunk']) }) + const out = serializeError(e) as { source: string } + expect(out.source).toBe('[object Stream]') + }) + + it('skips properties whose getters throw', () => { + const e = new Error('with throwing getter') + Object.defineProperty(e, 'broken', { + enumerable: true, + get() { + throw new Error('getter exploded') + }, + }) + const out = serializeError(e) as Record + expect(out.message).toBe('with throwing getter') + expect(out).not.toHaveProperty('broken') + }) + + it('truncates structures deeper than the configured maximum', () => { + interface Nested { + next?: Nested + } + const root: Nested = {} + let cursor = root + for (let i = 0; i < 20; i++) { + cursor.next = {} + cursor = cursor.next + } + const e = Object.assign(new Error('deep'), { tree: root }) + const out = serializeError(e) as { tree: Nested } + + const findTruncation = (node: unknown, depth = 0): number | null => { + if (node === '[Truncated]') return depth + if (!node || typeof node !== 'object') return null + const next = (node as Nested).next + return next === undefined ? null : findTruncation(next, depth + 1) + } + + expect(findTruncation(out.tree)).not.toBeNull() }) }) describe('serializableErrorReplacer', () => { - it('can serialize a nested error', () => { - const { - nested: { message, stack }, - } = JSON.parse(JSON.stringify({ nested: new Error('message') }, serializableErrorReplacer)) as { - nested: { - message: string - stack: string - } + it('replaces nested Error values during JSON.stringify', () => { + const out = JSON.parse(JSON.stringify({ nested: new Error('message') }, serializableErrorReplacer)) as { + nested: { message: string; stack: string } } - expect(message).toMatchInlineSnapshot(`"message"`) - expect(stack).toBeDefined() - expect(stack.split('\n').length).toBeGreaterThan(3) + expect(out.nested.message).toBe('message') + expect(out.nested.stack.split('\n').length).toBeGreaterThan(3) }) }) diff --git a/src/serialize-error.ts b/src/serialize-error.ts index db53e54..bc32629 100644 --- a/src/serialize-error.ts +++ b/src/serialize-error.ts @@ -1,5 +1,4 @@ import { JsonOptions } from 'logform' -import { serializeError as baseSerializeError } from 'serialize-error' /** * Signature for a function that converts an `Error` instance into a plain, JSON-serializable @@ -8,13 +7,83 @@ import { serializeError as baseSerializeError } from 'serialize-error' */ export type ErrorSerializer = (error: Error) => Record +// Stops runaway recursion on pathological structures. 16 is well past anything that occurs in +// realistic error trees; consumers who need deeper walks can supply a custom `errorSerializer`. +const MAX_DEPTH = 16 + +// Captured even when non-enumerable, which is the default for all of these on `Error` instances. +// `code` is enumerable in practice (Node attaches it as a regular own property) but listing it +// here is harmless and future-proofs against engines marking it non-enumerable. +const WELL_KNOWN_ERROR_PROPS = ['name', 'message', 'stack', 'code', 'cause', 'errors'] as const + +const isErrorLike = (value: unknown): value is Error => + value instanceof Error || + (typeof value === 'object' && + value !== null && + typeof (value as { name?: unknown }).name === 'string' && + typeof (value as { message?: unknown }).message === 'string' && + typeof (value as { stack?: unknown }).stack === 'string') + +const safeRead = (source: object, key: string | symbol): { ok: true; value: unknown } | { ok: false } => { + try { + return { ok: true, value: (source as Record)[key as string] } + } catch { + return { ok: false } + } +} + +const walk = (value: unknown, seen: WeakSet, depth: number): unknown => { + if (value === null || value === undefined) return value + const type = typeof value + if (type === 'bigint') return `${value as bigint}n` + if (type === 'function') return undefined + if (type !== 'object') return value + + const obj = value as object + if (typeof (obj as { pipe?: unknown }).pipe === 'function') return '[object Stream]' + if (obj instanceof Uint8Array) return `[object ${obj.constructor.name}]` + if (seen.has(obj)) return '[Circular]' + if (depth >= MAX_DEPTH) return '[Truncated]' + + seen.add(obj) + try { + if (Array.isArray(obj)) return obj.map((entry) => walk(entry, seen, depth + 1)) + + const out: Record = {} + for (const key of Object.keys(obj)) { + const read = safeRead(obj, key) + if (read.ok) out[key] = walk(read.value, seen, depth + 1) + } + if (isErrorLike(obj)) { + for (const key of WELL_KNOWN_ERROR_PROPS) { + if (key in out) continue + const read = safeRead(obj, key) + if (!read.ok || read.value === undefined || read.value === null) continue + out[key] = walk(read.value, seen, depth + 1) + } + } + return out + } finally { + seen.delete(obj) + } +} + /** - * Serialize an `Error` object into a plain object so that it can be serialized for logging. - * Delegates to the [`serialize-error`](https://www.npmjs.com/package/serialize-error) package, - * which captures `name`, `message`, `stack`, own enumerable properties, and recursively follows - * `cause` / nested `Error` values. + * Serialize an `Error` (or any thrown value) into a plain, JSON-serializable object. + * + * Captures `name`, `message`, `stack`, `code`, `cause`, and `errors` (AggregateError) even when + * they are non-enumerable, walks own enumerable properties, recurses into nested errors and + * objects, and is safe against circular references (cycles render as `'[Circular]'`). BigInts + * are stringified, functions are dropped, Buffers and streams render as sentinel strings, + * and recursion is capped at a fixed depth (`'[Truncated]'`). Non-Error throws (strings, + * numbers, etc.) are wrapped as `{ name: 'NonError', message: String(value) }`. */ -export const serializeError: ErrorSerializer = (error) => baseSerializeError(error) as Record +export const serializeError: ErrorSerializer = (error) => { + if (error === null || error === undefined || typeof error !== 'object') { + return { name: 'NonError', message: typeof error === 'function' ? '' : String(error) } + } + return walk(error, new WeakSet(), 0) as Record +} /** * Builds a JSON replacer that substitutes `Error` instances with the output of the supplied