Initial commit
[squeep-logger-json-console] / lib / logger.js
diff --git a/lib/logger.js b/lib/logger.js
new file mode 100644 (file)
index 0000000..a040bb8
--- /dev/null
@@ -0,0 +1,125 @@
+'use strict';
+
+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
+   */
+  constructor(options) {
+    const logLevels = Object.keys(Logger.nullLogger);
+    const ignoreBelowLevel = options && options.logger && options.logger.ignoreBelowLevel || 'debug';
+    this.backend = console;
+    this.nodeId = options.nodeId;
+    this.jsonReplacers = Object.values(jsonReplacers);
+    this.dataSanitizers = Object.values(dataSanitizers);
+
+    if (!logLevels.includes(ignoreBelowLevel)) {
+      throw new RangeError(`unrecognized minimum log level '${ignoreBelowLevel}'`);
+    }
+    const ignoreLevelIdx = logLevels.indexOf(ignoreBelowLevel);
+    logLevels.forEach((level) => {
+      // eslint-disable-next-line security/detect-object-injection
+      this[level] = (logLevels.indexOf(level) > ignoreLevelIdx) ?
+        nop :
+        (...args) => this.backend[level](this.payload(level, ...args)); // eslint-disable-line security/detect-object-injection
+    });
+  }
+
+
+  /**
+   * All the expected Console levels, but do nothing.
+   * Ordered from highest priority to lowest.
+   */
+  static get nullLogger() {
+    return {
+      error: nop,
+      warn: nop,
+      info: nop,
+      log: nop,
+      debug: nop,
+    };
+  }
+
+
+  /**
+   * 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
+   */
+  payload(level, scope, message, data, ...other) {
+    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)));
+      this.sanitize(data);
+    }
+
+    const now = new Date();
+    return JSON.stringify({
+      nodeId: this.nodeId,
+      timestamp: now.toISOString(),
+      timestampMs: now.getTime(),
+      level: level,
+      scope: scope || '[unknown]',
+      message: message || '',
+      data: data || {},
+      ...(other.length && { other }),
+    }, this.jsonReplacer.bind(this));
+  }
+
+
+  /**
+   * Determine if data needs sanitizing.
+   * @param {Object} data
+   * @returns {Boolean}
+   */
+  sanitizationNeeded(data) {
+    return this.dataSanitizers.some((sanitizer) => sanitizer(data, false));
+  }
+
+
+  /**
+   * Mogrify data.
+   * @param {Object} data
+   */
+  sanitize(data) {
+    this.dataSanitizers.forEach((sanitizer) => sanitizer(data));
+  }
+
+
+  /**
+   * Convert data into JSON.
+   * @param {String} _key
+   * @param {*} value
+   * @returns {String} serialized value
+   */
+  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;
+  }
+
+}
+
+module.exports = Logger;