diff --git a/lib/SimpleLogger.js b/lib/SimpleLogger.js index 46e3e86..265ed4a 100644 --- a/lib/SimpleLogger.js +++ b/lib/SimpleLogger.js @@ -3,6 +3,25 @@ const safeJSONStringify = require('safe-json-stringify'); const fastJSONStringify = require('fast-safe-stringify'); const LogLevel = require('./LogLevel'); +/* +* JSON.stringify (and fast-safe-stringify) only serialize enumerable own +* properties, so Error instances (whose `message` and `name` are +* non-enumerable) serialize to `{}`, or to a partial object when subclasses +* add own props like `code`/`$metadata`). This replacer flattens any Error +* value to a plain object that preserves `message` and `name` alongside any +* own enumerable props. +*/ +function errorReplacer(_key, value) { + if (value instanceof Error) { + return { + ...value, + message: value.message, + name: value.name, + }; + } + return value; +} + /* * This function safely stringifies JSON. If an exception occcurs (due to * circular references, exceptions thrown from object getters etc.), the module @@ -15,13 +34,13 @@ function safeStringify(obj) { let str; try { // Try to stringify the object (fast version) - str = fastJSONStringify(obj); + str = fastJSONStringify(obj, errorReplacer); // eslint-disable-next-line no-unused-vars } catch (e) { // fallback to remove circular object references or other exceptions // eslint-disable-next-line no-param-reassign obj.unsafeJSON = true; - return safeJSONStringify(obj); + return safeJSONStringify(obj, errorReplacer); } return str; } diff --git a/package.json b/package.json index 0748614..5b2bfce 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "engines": { "node": ">=20" }, - "version": "8.2.3", + "version": "8.2.4", "description": "An efficient raw JSON logging library aimed at micro-services architectures.", "main": "index.js", "scripts": { diff --git a/tests/unit/SimpleLogger.js b/tests/unit/SimpleLogger.js new file mode 100644 index 0000000..5ce9a8d --- /dev/null +++ b/tests/unit/SimpleLogger.js @@ -0,0 +1,75 @@ +const assert = require('assert'); +const { PassThrough } = require('stream'); + +const SimpleLogger = require('../../lib/SimpleLogger'); + +function captureLog(fn) { + const pass = new PassThrough(); + const records = []; + pass.on('data', data => records.push(data.toString())); + const logger = new SimpleLogger('test', [{ level: 'trace', stream: pass }]); + fn(logger); + return records.map(r => JSON.parse(r.trim())); +} + +describe('SimpleLogger Error serialization', () => { + it('serializes a plain Error with its message and name', () => { + const [entry] = captureLog(log => { + log.error({ error: new Error('boom') }, 'plain Error'); + }); + + assert.strictEqual(entry.error.message, 'boom'); + assert.strictEqual(entry.error.name, 'Error'); + }); + + it('preserves own enumerable props on Error subclasses', () => { + class S3ServiceException extends Error { + constructor(opts) { + super(opts.message); + this.name = opts.name; + } + } + const err = new S3ServiceException({ + name: 'NoSuchBucket', + message: 'The specified bucket does not exist', + }); + err.$metadata = { httpStatusCode: 404 }; + + const [entry] = captureLog(log => { + log.error({ error: err }, 'sdk v3 style'); + }); + + assert.strictEqual(entry.error.message, 'The specified bucket does not exist'); + assert.strictEqual(entry.error.name, 'NoSuchBucket'); + assert.deepStrictEqual(entry.error.$metadata, { httpStatusCode: 404 }); + }); + + it('does not include the stack', () => { + const [entry] = captureLog(log => { + log.error({ error: new Error('boom') }, 'no stack'); + }); + + assert.strictEqual(entry.error.stack, undefined); + }); + + it('leaves non-Error fields untouched', () => { + const [entry] = captureLog(log => { + log.info({ foo: 1, nested: { bar: 'baz' } }, 'plain object'); + }); + + assert.strictEqual(entry.foo, 1); + assert.deepStrictEqual(entry.nested, { bar: 'baz' }); + }); + + it('serializes Errors nested inside other fields', () => { + const [entry] = captureLog(log => { + log.error({ + // eslint-disable-next-line camelcase + healthStatus: { aws_location: { error: new Error('down') } }, + }, 'nested'); + }); + + assert.strictEqual(entry.healthStatus.aws_location.error.message, 'down'); + assert.strictEqual(entry.healthStatus.aws_location.error.name, 'Error'); + }); +});