X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Flogger.js;h=6cc4a2b4f30065b6d51e41de253d2267a780ee95;hb=refs%2Fheads%2Fmaster;hp=a040bb8ba3f9e759f3003e3af562c6c0fdc7d386;hpb=229dafe0003708b9fad190b4c0fc68136efd4f8c;p=squeep-logger-json-console diff --git a/lib/logger.js b/lib/logger.js index a040bb8..32d9d05 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -3,36 +3,61 @@ const jsonReplacers = require('./json-replacers'); const dataSanitizers = require('./data-sanitizers'); -/** - * Log as JSON to stdout/stderr. - */ - -const nop = () => { /**/ }; - +const nop = () => undefined; class Logger { /** - * Wrap backend calls with payload normalization. - * @param {Object} options - * @param {String} options.nodeId - unique identifier for running instance, usually a uuid - * @param {Object} options.logger - * @param {String} options.logger.ignoreBelowLevel - minimum level to log + * @callback LogFn + * @param {any} ...args values to log + * @returns {void} nothing + */ + /** + * @typedef {object} ConsoleLike + * @property {LogFn} error error level + * @property {LogFn} warn warn level + * @property {LogFn} info info level + * @property {LogFn} log log level + * @property {LogFn} debug debug level + */ + /** + * @typedef AsyncLocalStorage + * @property {Function} getStore local storage */ - constructor(options) { + /** + * @typedef {object} LoggerOptions + * @property {string} ignoreBelowLevel minimum level to log, e.g. 'info' + */ + /** + * Wrap backend calls with payload normalization and metadata. + * @param {LoggerOptions} options options + * @param {object} commonObject object to be merged into logged json + * @param {AsyncLocalStorage} asyncLocalStorage async storage for an object to be merged into logged json + * @param {ConsoleLike} backend default is console + */ + constructor(options, commonObject, asyncLocalStorage, backend) { const logLevels = Object.keys(Logger.nullLogger); - const ignoreBelowLevel = options && options.logger && options.logger.ignoreBelowLevel || 'debug'; - this.backend = console; - this.nodeId = options.nodeId; + const ignoreBelowLevel = options?.ignoreBelowLevel || 'debug'; + this.backend = backend || console; + this.commonObject = commonObject || {}; this.jsonReplacers = Object.values(jsonReplacers); this.dataSanitizers = Object.values(dataSanitizers); + if (asyncLocalStorage) { + // Override the class getter. + Object.defineProperty(this, 'asyncLogObject', { + enumerable: true, + get: asyncLocalStorage.getStore.bind(asyncLocalStorage), + }); + } + if (!logLevels.includes(ignoreBelowLevel)) { throw new RangeError(`unrecognized minimum log level '${ignoreBelowLevel}'`); } const ignoreLevelIdx = logLevels.indexOf(ignoreBelowLevel); logLevels.forEach((level) => { + const includeBackendLevel = (logLevels.indexOf(level) > ignoreLevelIdx) && this.backend[level]; // eslint-disable-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection - this[level] = (logLevels.indexOf(level) > ignoreLevelIdx) ? + this[level] = (includeBackendLevel) ? nop : (...args) => this.backend[level](this.payload(level, ...args)); // eslint-disable-line security/detect-object-injection }); @@ -42,6 +67,7 @@ class Logger { /** * All the expected Console levels, but do nothing. * Ordered from highest priority to lowest. + * @returns {object} no-op backend */ static get nullLogger() { return { @@ -54,25 +80,42 @@ class Logger { } + /** + * Default of empty object when no asyncLocalStorage is defined. + * Overridden on instance when asyncLocalStorage is defined. + * @returns {object} empty + */ + // eslint-disable-next-line class-methods-use-this + get asyncLogObject() { + return {}; + } + + /** * Structure all expected log data into JSON string. - * @param {String} level - * @param {String} scope - * @param {String} message - * @param {Object} data - * @param {...any} other - * @returns {String} JSON string + * @param {string} level log level + * @param {string} scope scope + * @param {string} message message + * @param {object} data data + * @param {...any} other more data merged into payload + * @returns {string} JSON string */ payload(level, scope, message, data, ...other) { + const replacer = this.getReplacer(); + if (this.sanitizationNeeded(data)) { // Create copy of data so we are not changing anything important. - data = JSON.parse(JSON.stringify(data, this.jsonReplacer.bind(this))); + try { + data = structuredClone(data); + } catch (e) { // eslint-disable-line no-unused-vars + data = JSON.parse(JSON.stringify(data, replacer)); + } this.sanitize(data); } const now = new Date(); - return JSON.stringify({ - nodeId: this.nodeId, + const logPayload = { + ...this.commonObject, timestamp: now.toISOString(), timestampMs: now.getTime(), level: level, @@ -80,14 +123,16 @@ class Logger { message: message || '', data: data || {}, ...(other.length && { other }), - }, this.jsonReplacer.bind(this)); + ...this.asyncLogObject, + }; + return JSON.stringify(logPayload, replacer); } /** * Determine if data needs sanitizing. - * @param {Object} data - * @returns {Boolean} + * @param {object} data data + * @returns {boolean} needs sanitization */ sanitizationNeeded(data) { return this.dataSanitizers.some((sanitizer) => sanitizer(data, false)); @@ -96,7 +141,7 @@ class Logger { /** * Mogrify data. - * @param {Object} data + * @param {object} data data */ sanitize(data) { this.dataSanitizers.forEach((sanitizer) => sanitizer(data)); @@ -104,20 +149,32 @@ class Logger { /** - * Convert data into JSON. - * @param {String} _key - * @param {*} value - * @returns {String} serialized value + * Return a replacer function which does de-cycling, as well as the rest of our replacers. + * @returns {Function} replacer */ - jsonReplacer(key, value) { - let replaced; - - // Try applying all our replacers, until one does something. - this.jsonReplacers.every((replacer) => { - ({ replaced, value } = replacer(key, value)); - return !replaced; - }); - return value; + getReplacer() { + const ancestors = []; + const loggerReplacers = this.jsonReplacers; + return function cycleReplacer(key, value) { + loggerReplacers.every((replacer) => { + const oldValue = value; + value = replacer(key, value); + return oldValue === value; + }); + if (typeof value === 'object' && value !== null) { + // 'this' is object where key/value came from + while (ancestors.length > 0 && ancestors.at(-1) !== this) { + ancestors.pop(); + } + if (ancestors.includes(value)) { + return '[Circular]'; + } else { + ancestors.push(value); + } + } + + return value; + }; } }