From 9237d0d41756c4f2fcea99c999bd6ad6f7575e01 Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Thu, 3 Sep 2026 10:22:43 -0700 Subject: [PATCH 1/2] feat(logger): serialize and log error cause chains in CloudflareLogger and ErrorHandler --- errors.js | 17 +++++++++++++-- logger.js | 52 +++++++++++++++++++++++++++++++++++++++------ test/logger.test.js | 35 ++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/errors.js b/errors.js index 23e9560..80a1e03 100644 --- a/errors.js +++ b/errors.js @@ -55,9 +55,22 @@ export class ErrorHandler { postTo.options.method ||= 'POST' let options = { ...postTo.options } - let dataStr = this.logger.data ? '\n\n' + JSON.stringify(this.logger.data, null, ' ') : '' + let causeStr = '' + let currentCause = err.cause + while (currentCause) { + if (currentCause instanceof Error) { + causeStr += `\n\nCaused by: ${currentCause.name}: ${currentCause.message}\n${currentCause.stack || ''}` + currentCause = currentCause.cause + } else if (typeof currentCause === 'object') { + causeStr += `\n\nCaused by: ${JSON.stringify(currentCause, null, ' ')}` + currentCause = currentCause.cause + } else { + causeStr += `\n\nCaused by: ${String(currentCause)}` + break + } + } - let message = `${err.name}: ${err.message}${dataStr}\n\n${err.stack}` + let message = `${err.name}: ${err.message}${causeStr}${dataStr}\n\n${err.stack}` if (this.options.appName) message = `${this.options.appName}\n${message}` if (options.body) { diff --git a/logger.js b/logger.js index c2b42d7..d21f7b5 100644 --- a/logger.js +++ b/logger.js @@ -74,7 +74,6 @@ export class CloudflareLogger { console[method](data) } - /** * This returns the object that will be passed to console.log() * @@ -107,11 +106,16 @@ export class CloudflareLogger { if (err) { data.message = err.message data.level = 'error' - data.error = { - message: err.message, - stack: err.stack, - status: err.status, - cause: err.cause, + data.error = serializeError(err) + if (err.cause) { + const causeMsg = formatCauseChain(err.cause) + if (causeMsg) { + if (data.message) { + data.message += ` (caused by: ${causeMsg})` + } else { + data.message = `caused by: ${causeMsg}` + } + } } } if (params.length > 0) { @@ -145,3 +149,39 @@ export class CloudflareLogger { return typeof value === 'object' && value !== null && !Array.isArray(value) && value.constructor === Object } } + +export function serializeError(err) { + if (!err) return null + if (!(err instanceof Error)) { + if (typeof err === 'object') return err + return { message: String(err) } + } + const data = { + name: err.name, + message: err.message, + status: err.status, + stack: err.stack, + } + if (err.cause !== undefined) { + data.cause = serializeError(err.cause) + } + return data +} + +function formatCauseChain(cause) { + const messages = [] + let curr = cause + while (curr) { + if (curr instanceof Error) { + if (curr.message) messages.push(curr.message) + curr = curr.cause + } else if (typeof curr === 'object' && curr.message) { + messages.push(curr.message) + curr = curr.cause + } else { + messages.push(String(curr)) + break + } + } + return messages.join(': ') +} diff --git a/test/logger.test.js b/test/logger.test.js index 7fb61cb..057809f 100644 --- a/test/logger.test.js +++ b/test/logger.test.js @@ -73,3 +73,38 @@ test('CloudflareLogger contextual cloning with .with()', () => { spyError.mockRestore() }) + +test('CloudflareLogger formats and serializes error cause chains', () => { + const logger = new CloudflareLogger({ data: { env: 'test' } }) + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const rootErr = new Error('root cause failure') + const outerErr = new Error('operation failed', { cause: rootErr }) + + logger.error('an error occurred', outerErr) + + expect(spyError).toHaveBeenCalledTimes(1) + const logged = spyError.mock.calls[0][0] + expect(logged.level).toBe('error') + expect(logged.message).toBe('an error occurred operation failed (caused by: root cause failure)') + expect(logged.error).toBeDefined() + expect(logged.error.message).toBe('operation failed') + expect(logged.error.cause).toBeDefined() + expect(logged.error.cause.message).toBe('root cause failure') + expect(logged.error.cause.stack).toBeDefined() + + // Test nested cause chains + const deepRoot = new Error('database connection timeout') + const midErr = new Error('query execution failed', { cause: deepRoot }) + const topErr = new Error('request handler failed', { cause: midErr }) + + logger.error(topErr) + expect(spyError).toHaveBeenCalledTimes(2) + const loggedNested = spyError.mock.calls[1][0] + expect(loggedNested.message).toBe( + 'request handler failed (caused by: query execution failed: database connection timeout)', + ) + expect(loggedNested.error.cause.cause.message).toBe('database connection timeout') + + spyError.mockRestore() +}) From da0279a55ec9602f09c25bf61e99bf8979b454eb Mon Sep 17 00:00:00 2001 From: Travis Reeder Date: Thu, 3 Sep 2026 10:41:07 -0700 Subject: [PATCH 2/2] fix(errors): restore dataStr and handle circular references in cause chains --- errors.js | 11 +++++++++++ logger.js | 19 +++++++++++++++---- test/logger.test.js | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/errors.js b/errors.js index 80a1e03..5028571 100644 --- a/errors.js +++ b/errors.js @@ -57,7 +57,16 @@ export class ErrorHandler { let causeStr = '' let currentCause = err.cause + const seenCauses = new WeakSet() + if (typeof err === 'object' && err !== null) seenCauses.add(err) while (currentCause) { + if (typeof currentCause === 'object' && currentCause !== null) { + if (seenCauses.has(currentCause)) { + causeStr += '\n\nCaused by: [Circular Reference]' + break + } + seenCauses.add(currentCause) + } if (currentCause instanceof Error) { causeStr += `\n\nCaused by: ${currentCause.name}: ${currentCause.message}\n${currentCause.stack || ''}` currentCause = currentCause.cause @@ -70,6 +79,8 @@ export class ErrorHandler { } } + let dataStr = this.logger?.data ? '\n\n' + JSON.stringify(this.logger.data, null, ' ') : '' + let message = `${err.name}: ${err.message}${causeStr}${dataStr}\n\n${err.stack}` if (this.options.appName) message = `${this.options.appName}\n${message}` diff --git a/logger.js b/logger.js index d21f7b5..3c3e6f5 100644 --- a/logger.js +++ b/logger.js @@ -108,7 +108,7 @@ export class CloudflareLogger { data.level = 'error' data.error = serializeError(err) if (err.cause) { - const causeMsg = formatCauseChain(err.cause) + const causeMsg = formatCauseChain(err.cause, new WeakSet([err])) if (causeMsg) { if (data.message) { data.message += ` (caused by: ${causeMsg})` @@ -150,12 +150,16 @@ export class CloudflareLogger { } } -export function serializeError(err) { +export function serializeError(err, seen = new WeakSet()) { if (!err) return null if (!(err instanceof Error)) { if (typeof err === 'object') return err return { message: String(err) } } + if (seen.has(err)) { + return { name: err.name, message: '[Circular Reference]', stack: err.stack } + } + seen.add(err) const data = { name: err.name, message: err.message, @@ -163,15 +167,22 @@ export function serializeError(err) { stack: err.stack, } if (err.cause !== undefined) { - data.cause = serializeError(err.cause) + data.cause = serializeError(err.cause, seen) } return data } -function formatCauseChain(cause) { +function formatCauseChain(cause, seen = new WeakSet()) { const messages = [] let curr = cause while (curr) { + if (typeof curr === 'object' && curr !== null) { + if (seen.has(curr)) { + messages.push('[Circular Reference]') + break + } + seen.add(curr) + } if (curr instanceof Error) { if (curr.message) messages.push(curr.message) curr = curr.cause diff --git a/test/logger.test.js b/test/logger.test.js index 057809f..99ec2c7 100644 --- a/test/logger.test.js +++ b/test/logger.test.js @@ -108,3 +108,20 @@ test('CloudflareLogger formats and serializes error cause chains', () => { spyError.mockRestore() }) + +test('CloudflareLogger handles circular references in error causes gracefully', () => { + const logger = new CloudflareLogger({ data: { env: 'test' } }) + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const cyclicErr = new Error('cyclic error') + cyclicErr.cause = cyclicErr + + logger.error(cyclicErr) + expect(spyError).toHaveBeenCalledTimes(1) + const logged = spyError.mock.calls[0][0] + expect(logged.level).toBe('error') + expect(logged.message).toBe('cyclic error (caused by: [Circular Reference])') + expect(logged.error.cause.message).toBe('[Circular Reference]') + + spyError.mockRestore() +})