Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,12 @@ Normalising the arguments also means the error instance is never written as the

Interpolation is left alone. A message holding a `util.format` token means the arguments after it are interpolation values rather than metadata — winston merges nothing onto the record for such a call — so `logger.error('failed: %s', err)` keeps the error in the splat position it was passed in, as does any `Error` past the metadata position. Those are still serialised by `serializeErrorFormat` where they lie, so `format.splat()` finds them there.

#### Values that serialise themselves

A value that defines `toJSON` — a `Date`, a `URL`, a date-library instance — is left as it is in log metadata, so a transport receives the `Date` itself and any JSON step writes its ISO string, as 1.x did. Inside a serialized error, where the output must be plain, the value is replaced by what its `toJSON` returns: `{ at: new Date(…) }` on an error becomes `at: '2026-01-02T03:04:05.000Z'`, and an invalid `Date` becomes `null`. An error is never asked for its `toJSON`, so one that defines it (an `AxiosError`, say) is still captured whole.

> **Fixed in 2.2.1.** From 2.0.0 to 2.2.0 a `Date` anywhere in log metadata reached every transport as `{}`.

> **Upgrading from 2.1.** `logger.error(err)` used to put `name`, `message` and `stack` at the top of the record; error detail now sits under `error` instead, matching `logger.error('msg', { error })`. Read `error.stack` rather than `stack`, and adjust any `omitPaths`/`redactPaths` that pointed at the old top-level keys.
>
> `logger.error('msg', err)` no longer has the error's message concatenated onto yours, and no longer copies `stack` to the top of the record: `message` is `'msg'`, and the detail is under `error`.
Expand Down
424 changes: 221 additions & 203 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makerx/node-winston",
"version": "2.2.0",
"version": "2.2.1",
"private": false,
"description": "A set of winston formats, console transport and logger creation functions",
"author": "MakerX",
Expand Down Expand Up @@ -40,7 +40,7 @@
"test:ci": "vitest run --coverage --reporter junit --outputFile test-results.xml"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@arethetypeswrong/cli": "^0.18.5",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "9.39.4",
"@makerx/eslint-config": "4.2.0",
Expand All @@ -54,7 +54,7 @@
"@types/node": "25.6.0",
"@typescript-eslint/eslint-plugin": "8.58.1",
"@typescript-eslint/parser": "8.58.1",
"@vitest/coverage-v8": "4.1.4",
"@vitest/coverage-v8": "^4.1.12",
"better-npm-audit": "^3.11.0",
"copyfiles": "^2.4.1",
"eslint": "9.39.4",
Expand All @@ -67,7 +67,7 @@
"tslib": "^2.8.1",
"tsx": "4.23.13",
"typescript": "^6.0.2",
"vitest": "^4.1.4"
"vitest": "^4.1.12"
},
"peerDependencies": {
"es-toolkit": ">=1",
Expand Down
41 changes: 41 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,47 @@ describe('createLogger error serialization', () => {
})
})

describe('createLogger Date metadata', () => {
const when = new Date('2026-01-02T03:04:05.000Z')

it('a transport receives a Date in metadata as the Date itself', () => {
const transport = new InMemoryTransport({})
const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] })
logger.info('db query', { parameters: [when, 1] })
const { parameters } = transport.logs[0] as { parameters: unknown[] }
expect(parameters[0]).toBe(when)
expect(JSON.stringify(parameters)).toBe('["2026-01-02T03:04:05.000Z",1]')
})

it('flatten writes a Date in metadata as its ISO string', () => {
const transport = new InMemoryTransport({})
const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], flatten: true })
logger.info('db query', { parameters: [when, 1], at: when })
const info = transport.logs[0] as Record<string, unknown>
expect(info.parameters).toBe('["2026-01-02T03:04:05.000Z",1]')
expect(info.at).toBe('"2026-01-02T03:04:05.000Z"')
})

it('a Date survives redaction beside a redacted key', () => {
const transport = new InMemoryTransport({})
const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport], redactPaths: ['token'] })
logger.info('grant', { token: 'secret', at: when })
const info = transport.logs[0] as Record<string, unknown>
expect(info.token).toBe('<redacted>')
expect(info.at).toBeInstanceOf(Date)
expect(JSON.stringify(info.at)).toBe('"2026-01-02T03:04:05.000Z"')
})

it('a Date logged beside an error is kept', () => {
const transport = new InMemoryTransport({})
const logger = createLogger({ consoleOptions: { silent: true }, transports: [transport] })
logger.error('db query error', { parameters: [when], error: new Error('timeout') })
const info = transport.logs[0] as { parameters: unknown[]; error: { message: string } }
expect(info.parameters[0]).toBe(when)
expect(info.error.message).toBe('timeout')
})
})

describe('createLogger omitPaths', () => {
it('removes top-level and nested dot-path fields', () => {
const transport = new InMemoryTransport({})
Expand Down
19 changes: 19 additions & 0 deletions src/serialize-error-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ describe('serializeErrorFormat', () => {
expect(arrErr.message).toBe('in-array')
})

it('passes a Date in metadata through as the same Date, not {}', () => {
const when = new Date('2026-01-02T03:04:05.000Z')
const result = run({ when, nested: { when }, list: [when] })
expect(result.when).toBe(when)
expect((result.nested as { when: unknown }).when).toBe(when)
expect((result.list as unknown[])[0]).toBe(when)
})

it('serializes an error that defines toJSON rather than passing it through', () => {
class HttpClientError extends Error {
toJSON() {
return { summary: 'trimmed' }
}
}
const result = run({ error: new HttpClientError('bad gateway') })
expect(result.error).toMatchObject({ message: 'bad gateway' })
expect(result.error).not.toBeInstanceOf(Error)
})

it('leaves non-error values untouched', () => {
const result = run({ a: 1, b: 'two', c: { nested: true } })
expect(result).toMatchObject({ a: 1, b: 'two', c: { nested: true } })
Expand Down
4 changes: 4 additions & 0 deletions src/serialize-error-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TransformableInfo } from 'logform'
import { SPLAT } from 'triple-beam'
import { format } from 'winston'
import { ErrorSerializer, serializeError } from './serialize-error'
import { serializesItself } from './serializes-itself'

export interface SerializeErrorFormatOptions {
/**
Expand Down Expand Up @@ -219,6 +220,9 @@ export const serializeErrorFormat = format((info, opts) => {
const walk = (value: unknown, seen: WeakSet<object>): unknown => {
if (value instanceof Error) return serializer(value)
if (!value || typeof value !== 'object') return value
// Passed through as the same reference, as 1.x did: a transport receives the `Date` itself and
// the JSON step writes its ISO string. Rebuilt from its own keys, a `Date` would be `{}`.
if (serializesItself(value)) return value
if (seen.has(value)) return '[Circular]'
seen.add(value)
try {
Expand Down
38 changes: 38 additions & 0 deletions src/serialize-error.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,42 @@ describe('createSerializableErrorReplacer', () => {
const result = JSON.parse(JSON.stringify({ nested: new Error('message') }, replacer)) as { nested: { marker: string } }
expect(result.nested.marker).toBe('custom')
})

it('writes a Date property as its ISO string, as JSON.stringify would, not as {}', () => {
const error = Object.assign(new Error('boom'), { at: new Date('2026-01-02T03:04:05.000Z') })
expect(serializeError(error).at).toBe('2026-01-02T03:04:05.000Z')
})

it('writes an invalid Date property as null rather than throwing', () => {
const error = Object.assign(new Error('boom'), { at: new Date('not a date') })
expect(serializeError(error).at).toBeNull()
})

it('writes a URL property as its href', () => {
const error = Object.assign(new Error('boom'), { url: new URL('https://example.test/a?b=1') })
expect(serializeError(error).url).toBe('https://example.test/a?b=1')
})

it("walks a value's own keys when its toJSON throws", () => {
const awkward = {
id: 7,
toJSON: () => {
throw new Error('no')
},
}
expect(serializeError(Object.assign(new Error('boom'), { awkward })).awkward).toEqual({ id: 7 })
})

it('never asks an error for its toJSON, so an error that defines one is still captured whole', () => {
class HttpClientError extends Error {
readonly status = 502
toJSON() {
return { summary: 'trimmed' }
}
}
const out = serializeError(new HttpClientError('bad gateway'))
expect(out).toMatchObject({ message: 'bad gateway', status: 502 })
expect(out.stack).toBeDefined()
expect(out.summary).toBeUndefined()
})
})
22 changes: 21 additions & 1 deletion src/serialize-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { JsonOptions } from 'logform'
import { serializesItself } from './serializes-itself'

/**
* Signature for a function that converts an `Error` instance into a plain, JSON-serializable
Expand Down Expand Up @@ -32,6 +33,16 @@ const safeRead = (source: object, key: string | symbol): { ok: true; value: unkn
}
}

/** `toJSON`'s result, or nothing when it throws or hands back the value itself. */
const safeToJSON = (source: object): { ok: true; value: unknown } | { ok: false } => {
try {
const value: unknown = (source as { toJSON: () => unknown }).toJSON()
return value === source ? { ok: false } : { ok: true, value }
} catch {
return { ok: false }
}
}

const walk = (value: unknown, seen: WeakSet<object>, depth: number): unknown => {
if (value === null || value === undefined) return value
const type = typeof value
Expand All @@ -49,6 +60,14 @@ const walk = (value: unknown, seen: WeakSet<object>, depth: number): unknown =>
try {
if (Array.isArray(obj)) return obj.map((entry) => walk(entry, seen, depth + 1))

// What `JSON.stringify` would have written: a `Date` becomes its ISO string (`null` when
// invalid) rather than `{}`. An error is never asked, so one that defines `toJSON` — an
// `AxiosError`, say — is still captured whole below.
if (!isErrorLike(obj) && serializesItself(obj)) {
const json = safeToJSON(obj)
if (json.ok) return walk(json.value, seen, depth + 1)
}

const out: Record<string, unknown> = {}
for (const key of Object.keys(obj)) {
const read = safeRead(obj, key)
Expand All @@ -73,7 +92,8 @@ const walk = (value: unknown, seen: WeakSet<object>, depth: number): unknown =>
*
* 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
* objects, and is safe against circular references (cycles render as `'[Circular]'`). A value
* that defines `toJSON` (a `Date`, a `URL`) is replaced by what it returns. 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) }`.
Expand Down
8 changes: 8 additions & 0 deletions src/serializes-itself.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Internal: deliberately not re-exported from index.ts.

/**
* Whether a value serialises itself through `toJSON`, as a `Date`, a `URL` or a date-library
* instance does. `JSON.stringify` calls it, so these values are left for the JSON step rather than
* rebuilt from their own enumerable keys — a `Date` has none, and would otherwise render as `{}`.
*/
export const serializesItself = (value: object): boolean => typeof (value as { toJSON?: unknown }).toJSON === 'function'
Loading