fix ordering of replacing and de-cycling to work correctly in some situations
[squeep-logger-json-console] / lib / logger.js
index a040bb8ba3f9e759f3003e3af562c6c0fdc7d386..3f336ab4c7121d17fbeaca6eacfee45bc678c043 100644 (file)
@@ -3,36 +3,52 @@
 const jsonReplacers = require('./json-replacers');
 const dataSanitizers = require('./data-sanitizers');
 
-/**
- * Log as JSON to stdout/stderr.
- */
-
 const nop = () => { /**/ };
 
-
 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
+   * @typedef {Object} ConsoleLike
+   * @property {Function(...):void} error
+   * @property {Function(...):void} warn
+   * @property {Function(...):void} info
+   * @property {Function(...):void} log
+   * @property {Function(...):void} debug
    */
-  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
+   * @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
     });
@@ -54,6 +70,16 @@ class Logger {
   }
 
 
+  /**
+   * Default of empty object when no asyncLocalStorage is defined.
+   * Overridden on instance when asyncLocalStorage is defined.
+   */
+  // eslint-disable-next-line class-methods-use-this
+  get asyncLogObject() {
+    return {};
+  }
+
+
   /**
    * Structure all expected log data into JSON string.
    * @param {String} level
@@ -64,15 +90,17 @@ class Logger {
    * @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)));
+      data = structuredClone(data);
       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,7 +108,9 @@ class Logger {
       message: message || '',
       data: data || {},
       ...(other.length && { other }),
-    }, this.jsonReplacer.bind(this));
+      ...this.asyncLogObject,
+    };
+    return JSON.stringify(logPayload, replacer);
   }
 
 
@@ -104,20 +134,31 @@ 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.
    */
-  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)) { // eslint-disable-line security/detect-object-injection
+          return '[Circular]';
+        } else {
+          ancestors.push(value);
+        }
+      }
+
+      return value;
+    }
   }
 
 }