Skip to content

Commit 0b193db

Browse files
committed
fix(logging): redact bound params in colorized, nested, and exported errors
1 parent 1beffd3 commit 0b193db

2 files changed

Lines changed: 130 additions & 67 deletions

File tree

‎packages/logger/src/index.test.ts‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,29 @@ describe('Logger', () => {
193193
const createEnabledLogger = () =>
194194
new Logger('Test', { enabled: true, colorize: false, logLevel: LogLevel.DEBUG })
195195

196-
/** Mirrors Drizzle's `DrizzleQueryError`: SQL plus bound values, wrapping the driver error. */
196+
/**
197+
* Mirrors Drizzle's `DrizzleQueryError`: SQL plus bound values in the message, and `query`,
198+
* `params` and `cause` as own enumerable properties, wrapping the driver error.
199+
*/
197200
const queryError = (params: string) => {
198201
const cause = Object.assign(new Error('canceling statement due to statement timeout'), {
199202
name: 'PostgresError',
200203
code: '57014',
204+
detail: `Key (email)=(${params}) already exists.`,
205+
})
206+
const query = 'select "id" from "user_table_rows" where "table_id" = $1 limit $2'
207+
const error = Object.assign(new Error(`Failed query: ${query}\nparams: ${params}`), {
208+
query,
209+
params: params.split(','),
210+
cause,
201211
})
202-
const error = new Error(
203-
`Failed query: select "id" from "user_table_rows" where "table_id" = $1 limit $2\nparams: ${params}`,
204-
{ cause }
205-
)
206212
error.name = 'DrizzleQueryError'
207213
return error
208214
}
209215

216+
const consoleOutput = () =>
217+
[...consoleLogSpy.mock.calls, ...consoleErrorSpy.mock.calls].flat().join(' ')
218+
210219
test('names the deepest cause and its code on the line', () => {
211220
createEnabledLogger().error('Failed to query rows:', { error: queryError('tbl_1,52') })
212221

@@ -233,19 +242,43 @@ describe('Logger', () => {
233242
expect(parsed.stack).toMatch(/\n\s+at /)
234243
})
235244

236-
test('keeps bound values out of an error logged under another key', () => {
237-
createEnabledLogger().error('Insert failed', { dbError: queryError('alice@example.com') })
245+
test.each([
246+
['under another key', (error: Error) => ({ dbError: error })],
247+
['nested inside metadata', (error: Error) => ({ details: { attempt: 2, error } })],
248+
])('keeps bound values out of an error logged %s', (_, arg) => {
249+
createEnabledLogger().error('Insert failed', arg(queryError('alice@example.com')))
238250

239-
expect(consoleErrorSpy.mock.calls[0][0] as string).not.toContain('alice@example.com')
251+
expect(consoleOutput()).not.toContain('alice@example.com')
240252
})
241253

242-
test('keeps bound values out of the exported log record', () => {
254+
test.each([
255+
['an object field', (error: Error) => [{ error }]],
256+
['a bare argument', (error: Error) => [error]],
257+
['nested inside metadata', (error: Error) => [{ details: { error } }]],
258+
])('keeps bound values out of colorized output when passed as %s', (_, args) => {
259+
new Logger('Test', { enabled: true, colorize: true, logLevel: LogLevel.DEBUG }).error(
260+
'Failed to query rows:',
261+
...args(queryError('alice@example.com'))
262+
)
263+
264+
const output = consoleOutput()
265+
expect(output).toContain('params: [redacted]')
266+
expect(output).not.toContain('alice@example.com')
267+
})
268+
269+
test.each([
270+
['a bare argument', (error: Error) => [error]],
271+
['an object field', (error: Error) => [{ error }]],
272+
])('keeps bound values out of the exported log record when passed as %s', (_, args) => {
243273
const emit = vi.fn()
244274
const getLoggerSpy = vi
245275
.spyOn(logs, 'getLogger')
246276
.mockReturnValue({ emit, enabled: () => true })
247277
try {
248-
createEnabledLogger().error('Failed to query rows:', queryError('alice@example.com'))
278+
createEnabledLogger().error(
279+
'Failed to query rows:',
280+
...args(queryError('alice@example.com'))
281+
)
249282

250283
const { attributes } = emit.mock.calls[0][0]
251284
expect(JSON.stringify(attributes)).not.toContain('alice@example.com')
@@ -257,6 +290,18 @@ describe('Logger', () => {
257290
}
258291
})
259292

293+
test('emits a line for a nested error that references itself', () => {
294+
const error = Object.assign(new Error('self-referencing failure'), {
295+
context: {} as Record<string, unknown>,
296+
})
297+
error.context.error = error
298+
299+
expect(() => createEnabledLogger().error('Failed', { details: { error } })).not.toThrow()
300+
const parsed = JSON.parse(consoleErrorSpy.mock.calls[0][0] as string)
301+
expect(parsed.details.error.message).toBe('self-referencing failure')
302+
expect(parsed.details.error.context.error).toBe('[Circular]')
303+
})
304+
260305
test('leaves an unwrapped error without cause fields', () => {
261306
createEnabledLogger().error('Request failed', { error: new Error('plain failure') })
262307

‎packages/logger/src/index.ts‎

Lines changed: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -132,61 +132,14 @@ const getLogConfig = () => {
132132
}
133133
}
134134

135-
/**
136-
* Renders an error as the plain object `JSON.stringify` cannot produce for it.
137-
*
138-
* `message`, `stack` and `name` are non-enumerable on `Error.prototype`, so a
139-
* plain stringify emits `{}`. Own enumerable properties are copied too — driver
140-
* and HTTP errors carry the useful part (`code`, `status`) there.
141-
*/
142-
const errorToPlainObject = (error: Error, isDev: boolean): Record<string, unknown> => {
143-
const errorObj: Record<string, unknown> = {
144-
message: error.message,
145-
stack: isDev ? error.stack : undefined,
146-
name: error.name,
147-
}
148-
for (const key of Object.keys(error)) {
149-
if (!(key in errorObj)) {
150-
errorObj[key] = (error as unknown as Record<string, unknown>)[key]
151-
}
152-
}
153-
return errorObj
154-
}
155-
156-
/**
157-
* Format objects for logging
158-
*
159-
* Errors held under a key are unwrapped as well as bare ones — `{ error }` is
160-
* the common call shape, and it would otherwise print as `{"error":{}}`.
161-
*/
162-
const formatObject = (obj: unknown, isDev: boolean): string => {
163-
try {
164-
if (obj instanceof Error) {
165-
return JSON.stringify(errorToPlainObject(obj, isDev), null, isDev ? 2 : 0)
166-
}
167-
if (isRecordLike(obj)) {
168-
let unwrapped: Record<string, unknown> | undefined
169-
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
170-
if (!(value instanceof Error)) continue
171-
unwrapped ??= { ...(obj as Record<string, unknown>) }
172-
unwrapped[key] = errorToPlainObject(value, isDev)
173-
}
174-
if (unwrapped) {
175-
return JSON.stringify(unwrapped, null, isDev ? 2 : 0)
176-
}
177-
}
178-
return JSON.stringify(obj, null, isDev ? 2 : 0)
179-
} catch {
180-
return '[Circular or Non-Serializable Object]'
181-
}
182-
}
183-
184135
interface LoggedError {
185136
message: string
186137
stack?: string
187138
/** `"Name: message"` of the deepest `.cause` link, present only when the error wraps another. */
188139
cause?: string
189140
code?: string
141+
/** Whether the message carried a bound-parameter tail, marking the error as a query wrapper. */
142+
redacted: boolean
190143
}
191144

192145
/**
@@ -212,9 +165,61 @@ const toLoggedError = (error: Error): LoggedError => {
212165
stack,
213166
...(described.causeChain ? { cause: `${described.name}: ${described.message}` } : {}),
214167
...(described.code ? { code: described.code } : {}),
168+
redacted: message !== error.message,
215169
}
216170
}
217171

172+
/**
173+
* Renders an error as the plain object `JSON.stringify` cannot produce for it.
174+
*
175+
* `message`, `stack` and `name` are non-enumerable on `Error.prototype`, so a
176+
* plain stringify emits `{}` — or, for an error with own enumerable properties,
177+
* only those. Own properties are copied because driver and HTTP errors carry the
178+
* useful part (`code`, `status`) there, except what `toLoggedError` replaces: a
179+
* query wrapper's `params` holds the redacted values, and a summarized `cause`
180+
* would carry the driver's `detail`.
181+
*/
182+
const toPlainError = (error: Error, includeStack: boolean): Record<string, unknown> => {
183+
const logged = toLoggedError(error)
184+
const plain: Record<string, unknown> = {
185+
message: logged.message,
186+
stack: includeStack ? logged.stack : undefined,
187+
name: error.name,
188+
...(logged.cause ? { cause: logged.cause } : {}),
189+
...(logged.code ? { code: logged.code } : {}),
190+
}
191+
for (const key of Object.keys(error)) {
192+
if (key in plain || (logged.redacted && key === 'params')) continue
193+
plain[key] = (error as unknown as Record<string, unknown>)[key]
194+
}
195+
return plain
196+
}
197+
198+
/** JSON replacer that serializes every `Error`, however deeply nested, as its plain form. */
199+
const errorReplacer =
200+
(includeStack: boolean) =>
201+
(_key: string, value: unknown): unknown =>
202+
value instanceof Error ? toPlainError(value, includeStack) : value
203+
204+
/** Format objects for logging. */
205+
const formatObject = (obj: unknown, isDev: boolean): string => {
206+
try {
207+
return JSON.stringify(obj, errorReplacer(isDev), isDev ? 2 : 0)
208+
} catch {
209+
return '[Circular or Non-Serializable Object]'
210+
}
211+
}
212+
213+
/** The error a line is about: the first bare `Error` argument, else the first `{ error }` field. */
214+
const primaryError = (args: unknown[]): Error | undefined => {
215+
const bare = args.find((arg) => arg instanceof Error)
216+
if (bare) return bare as Error
217+
for (const arg of args) {
218+
if (isRecordLike(arg) && arg.error instanceof Error) return arg.error
219+
}
220+
return undefined
221+
}
222+
218223
/** Adds an error's cause fields to the entry without displacing caller-supplied values. */
219224
const assignErrorCause = (entry: Record<string, unknown>, logged: LoggedError) => {
220225
if (logged.cause !== undefined && entry.errorCause === undefined) entry.errorCause = logged.cause
@@ -262,9 +267,14 @@ const mergeArgs = (entry: Record<string, unknown>, args: unknown[]): Record<stri
262267
return entry
263268
}
264269

265-
/** JSON replacer that tolerates cyclic references and BigInt values. */
270+
/**
271+
* JSON replacer that tolerates cyclic references and BigInt values, and
272+
* serializes errors as their plain form like `errorReplacer`.
273+
*/
266274
const tolerantReplacer = () => {
267275
const ancestors: object[] = []
276+
/** What `JSON.stringify` descends into for each ancestor — an error's plain form, not the error. */
277+
const holders: object[] = []
268278
return function (this: unknown, _key: string, value: unknown): unknown {
269279
if (typeof value === 'bigint') return value.toString()
270280
if (value === null || typeof value !== 'object') return value
@@ -275,10 +285,15 @@ const tolerantReplacer = () => {
275285
* appearance of a merely repeated reference `[Circular]` and discard real
276286
* data, since a payload that references one object twice has no cycle.
277287
*/
278-
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
288+
while (holders.length > 0 && holders[holders.length - 1] !== this) {
289+
holders.pop()
290+
ancestors.pop()
291+
}
279292
if (ancestors.includes(value)) return '[Circular]'
293+
const serialized = value instanceof Error ? toPlainError(value, false) : value
280294
ancestors.push(value)
281-
return value
295+
holders.push(serialized)
296+
return serialized
282297
}
283298
}
284299

@@ -292,7 +307,7 @@ const tolerantReplacer = () => {
292307
*/
293308
const serializeEntry = (base: Record<string, unknown>, args: unknown[]): string => {
294309
try {
295-
return JSON.stringify(mergeArgs({ ...base }, args))
310+
return JSON.stringify(mergeArgs({ ...base }, args), errorReplacer(false))
296311
} catch {}
297312

298313
try {
@@ -628,17 +643,20 @@ function emitOtelLogRecord(
628643
for (const [key, value] of Object.entries(filterUndefined(metadata))) {
629644
attributes[key] = String(value)
630645
}
631-
const firstError = args.find((arg) => arg instanceof Error) as Error | undefined
632-
if (firstError) {
633-
const logged = toLoggedError(firstError)
646+
const error = primaryError(args)
647+
if (error) {
648+
const logged = toLoggedError(error)
634649
attributes['error.message'] = logged.message
635650
if (logged.stack) attributes['error.stack'] = logged.stack
636651
if (logged.cause) attributes['error.cause'] = logged.cause
637652
}
638653
const plainArgs = args.filter((arg) => !(arg instanceof Error))
639654
if (plainArgs.length > 0) {
640655
try {
641-
attributes['log.args'] = JSON.stringify(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS)
656+
attributes['log.args'] = JSON.stringify(plainArgs, errorReplacer(false)).slice(
657+
0,
658+
OTEL_LOG_ARG_MAX_CHARS
659+
)
642660
} catch {
643661
attributes['log.args'] = String(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS)
644662
}

0 commit comments

Comments
 (0)