log/Log.js

/**
 * @copyright 2016 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/**
 * @module nmodule/js/rc/log/Log
 */
define([
  'module',
  'Promise',
  'underscore',
  'nmodule/js/rc/asyncUtils/asyncUtils',
  'nmodule/js/rc/log/Level',
  'nmodule/js/rc/log/handlers/consoleHandler' ], function (
  module,
  Promise,
  _,
  asyncUtils,
  Level,
  consoleHandler) {

  'use strict';
  
  const { contains, extend, findIndex, isFunction, map, once } = _;
  const { doRequire } = asyncUtils;
  const DEFAULT_LEVEL = Level.INFO;
  const CONSOLE_LOG_NAME = 'browser.console';
  const slice = Array.prototype.slice;

  const configuredLogLevels = {};

  var ticketId = 0;

  /**
   * Cache of Log promises by log name.
   * @inner
   * @type {Object.<string, Promise.<module:nmodule/js/rc/log/Log>>}
   */
  var getLoggerPromises = {};

  /**
   * Responsible for actually publishing a log message to some destination
   * (console, `baja.outln`, logfile, etc).
   * @typedef {Object} module:nmodule/js/rc/log/Log~Handler
   * @property {module:nmodule/js/rc/log/Log~PublishCallback} publish implements
   * how log messages will be handled
   */

  /**
   * When adding a custom Handler, implement the `publish` function to define
   * how log messages will be handled.
   *
   * @callback module:nmodule/js/rc/log/Log~PublishCallback
   * @param {string} name log name
   * @param {module:nmodule/js/rc/log/Log.Level} level log level
   * @param {string} msg an already fully-formatted log message.
   * @returns {Promise}
   */


  /**
   * Class for logging messages throughout Niagara JS apps. Do not instantiate
   * this class directly: rather use the {@link module:nmodule/js/rc/log/Log.getLogger getLogger()} function.
   * 
   * Logs support SLF4J-style parameterization. See example.
   *
   * @class
   * @alias module:nmodule/js/rc/log/Log
   * @see module:log
   * 
   * @example
   * <caption>Supports SLF4J-style format anchors.</caption>
   * return Log.getLogger('my.package.name')
   *   .then(function (log) {
   *     return log.log(Log.Level.INFO, 'foo was {} and bar was {}', 'foo', 'bar');
   *   });
   *   
   * @example
   * <caption>Supports a trailing Error argument.</caption>
   * doSomethingAsync()
   *   .catch(function (err) {
   *     return Log.logMessage('my.package.name', Log.Level.SEVERE,
   *       '{} rejected with error', 'doSomethingAsync', err);
   *   });
   *
   * @example
   * <caption>Has convenience methods for behaving like the console.</caption>
   * define(['nmodule/js/rc/log/Log'], function (console) {
   *   //Note that all of these create and return Promises behind the scenes.
   *   //The log name will be browser.console.
   *   console.log('this logs at', 'FINE', 'level');
   *   console.info('this logs at', 'INFO', 'level');
   *   console.warn('this logs at', 'WARNING', 'level');
   *   console.error('this logs at', 'SEVERE', 'level');
   * });
   */
  var Log = function Log(name, handlers) {
    this.$name = name;
    this.$handlers = handlers || [];
    this.$timers = [];
  };

  var getConfiguredHandlers = once(function () {
    // noinspection JSUnresolvedVariable
    var c = module.config(),
      handlers = (c && c.logHandlers) || [];
    return Promise.all(handlers.map(doRequire));
  });

  var applyConfiguredLogLevels = once(function () {
    // noinspection JSUnresolvedVariable
    var c = module.config(),
      logLevels = c && c.logLevels;
    Log.$applyLogLevels(logLevels);
  });

  /**
   * @private
   * @param {Object} logLevels log name -> log level name map
   */
  Log.$applyLogLevels = function (logLevels) {
    extend(configuredLogLevels, logLevels);
  };
  
  /**
   * Convenience method to stop you from having to resolve the `Log.getLogger()`
   * promise every time you wish to log a message. This will combine the lookup
   * and log into one step.
   *
   * Note that you cannot perform an `isLoggable()` check with this method, so
   * if your log message is expensive to generate, you may want to fully resolve
   * the logger first.
   *
   * @param {string} name
   * @param {module:nmodule/js/rc/log/Log.Level} level
   * @param {String} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise} promise to be resolved when the message has been logged
   */
  Log.logMessage = function (name, level, msg, args) {
    var slicedArgs = slice.call(arguments, 1);
    return Log.getLogger(name)
      .then(function (log) {
        return log.log.apply(log, slicedArgs);
      });
  };

  /**
   * Logs a message to the `browser.console` log at `FINE` level.
   * This matches a browser's `console.log` API.
   *
   * @returns {Promise}
   * @example
   *  Log.log('this', 'is', 'a', 'fine', 'message');
   */
  Log.log = function () {
    return consoleLog(Level.FINE, arguments);
  };

  /**
   * Logs a message to the `browser.console` log at `INFO` level.
   * This matches a browser's `console.info` API.
   *
   * @returns {Promise}
   * @example
   *  Log.info('this', 'is', 'an', 'info', 'message');
   */
  Log.info = function () {
    return consoleLog(Level.INFO, arguments);
  };

  /**
   * Logs a message to the `browser.console` log at `WARNING` level.
   * This matches a browser's `console.warn` API.
   *
   * @returns {Promise}
   * @example
   *  Log.warn('this', 'is', 'a', 'warning', 'message');
   */
  Log.warn = function (msg) {
    return consoleLog(Level.WARNING, arguments);
  };

  /**
   * Logs a message to the `browser.console` log at `SEVERE` level.
   * This matches a browser's `console.error` API.
   *
   * @returns {Promise}
   * @example
   *  Log.error('this', 'is', 'an', 'error', 'message');
   */
  Log.error = function () {
    return consoleLog(Level.SEVERE, arguments);
  };

  /**
   * Resolve a Log instance with the given name.
   *
   * @param {string} name name for the log to retrieve. Calling `getLogger()`
   * twice for the same name will resolve the same Log instance.
   * @returns {Promise.<module:nmodule/js/rc/log/Log>}
   */
  Log.getLogger = function (name) {
    if (getLoggerPromises[name]) {
      return getLoggerPromises[name];
    }

    applyConfiguredLogLevels();
    var prom = getConfiguredHandlers()
      .then(function (handlers) {
        var log = new Log(name, handlers.concat(consoleHandler));
        getLoggerPromises[name] = Promise.resolve(log);
        return log;
      });
    getLoggerPromises[name] = prom;
    return prom;
  };

  /**
   * Add a new handler to this Log. The same handler instance cannot be added
   * to the same log more than once.
   * @param {module:nmodule/js/rc/log/Log~Handler} handler
   */
  Log.prototype.addHandler = function (handler) {
    if (!handler || !isFunction(handler.publish)) {
      throw new Error('handler with publish function required');
    }
    var handlers = this.$handlers;
    if (!contains(handlers, handler)) {
      handlers.push(handler);
    }
  };

  /**
   * Get the level configured for this log.
   * @returns {module:nmodule/js/rc/log/Log.Level}
   */
  Log.prototype.getLevel = function () {
    return this.$level || 
      (this.$level = getLevel(this.$name, configuredLogLevels) || DEFAULT_LEVEL);
  };

  /**
   * Get the log's name.
   * @returns {string}
   */
  Log.prototype.getName = function () {
    return this.$name;
  };

  /**
   * Return true if a log message at the given log level will actually be logged
   * by this logger. Use this to improve performance if a log message would be
   * expensive to create.
   * @param {module:nmodule/js/rc/log/Log.Level|string} level
   * @returns {boolean}
   */
  Log.prototype.isLoggable = function (level) {
    if (typeof level === 'string') {
      level = Level[level];
    }
    if (!level) {
      throw new Error('level required');
    }

    var myLevel = this.getLevel();
    return myLevel !== Level.OFF && myLevel.intValue() <= level.intValue();
  };

  /**
   * Log the given message, giving all `Handler`s attached to this Log a chance
   * to publish it.
   *
   * @param {module:nmodule/js/rc/log/Log.Level|string} level Log level object,
   * or the name of it as a string
   * @param {String} msg log message
   * @param {...*} [args] additional arguments to use for parameterization. If
   * the final argument is an Error, it will be logged by itself.
   *
   * @returns {Promise} promise to be resolved when all handlers are finished
   * publishing
   *
   * @example
   * const name = promptForName();
   * log.log(Level.FINE, 'Hello, {}!', name);
   *
   * try {
   *   doSomething();
   * } catch (err) {
   *   log.log('SEVERE', 'An error occurred at {}', new Date(), err);
   * }
   */
  Log.prototype.log = function (level, msg, args) {
    if (!this.isLoggable(level)) {
      return Promise.resolve();
    }

    var name = this.getName();
    let str;

    return slf4jFormatAsync(msg, slice.call(arguments, 2))
      .then((msgStr) => {
        str = msgStr;
        return Promise.all(map(this.$handlers, function (h) {
          return Promise.resolve(h.publish(name, Log.Level[level], str))
            .catch(function (err) {
              return consoleHandler.publish(CONSOLE_LOG_NAME, Level.SEVERE,
                slf4jFormat('Log handler failed to publish message', [ err ]));
            });
        }));
      });
  };

  /**
   * Starts timing a particular operation.
   *
   * @param {string} id a "unique" ID to describe this operation.
   * @param {module:nmodule/js/rc/log/Log.Level|string} [level=INFO] Log level
   * object, or the name of it as a string
   * @param {string} [msg] a message to log. If omitted, will simply log the ID
   * @param {...*} [args] additional arguments to format the message
   * @returns {number} a ticket that may be passed to `timeEnd`
   * @throws {Error} if ID not provided
   */
  Log.prototype.time = function (id, level, msg, args) {
    level = level || DEFAULT_LEVEL;

    var timers = this.$timers;
    var ticket = ++ticketId;
    timers.push({ id: id, start: +new Date(), ticket: ticket, level: level });
    if (msg || id) {
      this.log.apply(this,
        [ level, msg || id ].concat(Array.prototype.slice.call(arguments, 3)))
        .catch(function (ignore) {});
    }
    return ticket;
  };

  /**
   * Stops timing a particular operation.
   *
   * @param {string|number} [id] the ID passed to `time()` (if duplicates are
   * found, this will stop the least recently started timer). Or, pass the exact
   * ticket returned from `time()`. If omitted, this call will be a no-op (this
   * makes it safe to wrap `time()` calls in isLoggable checks).
   * @param {string} [msg="id: {}ms"] a message to log. For format arguments,
   * the number of milliseconds elapsed is always the last.
   * @param {...*} [args] any additional arguments to use to format the message
   * - remember elapsed time will always be added
   */
  Log.prototype.timeEnd = function (id, msg, args) {
    if (!id) { return; }

    var timer = this.$getTimer(id);
    if (!timer) {
      return this.log('WARNING', 'Timer {} does not exist', id);
    }
    var elapsedTime = this.$getElapsedTime(timer);
    this.log.apply(this,
      [ timer.level, msg || timer.id + ': {}ms' ]
        .concat(Array.prototype.slice.call(arguments, 2))
        .concat(elapsedTime))
      .catch(function (ignore) {});
  };

  Log.prototype.timing = function (func, level, msg) {
    var that = this;
    var prom = Promise.resolve(func());

    if (!that.isLoggable(level)) {
      return prom;
    }

    var ticket = that.time('timing', level);
    var args = Array.prototype.slice.call(arguments, 2);
    return prom
      .then(function (result) {
        that.timeEnd.apply(that, [ ticket ].concat(args));
        return result;
      });
  };

  /**
   * @private
   * @returns {object}
   */
  Log.prototype.$getTimer = function (id) {
    if (!id) { throw new Error('id required'); }

    var timers = this.$timers;
    var index;

    if (typeof id === 'number') {
      index = findIndex(timers, function (timer) {
        return timer.ticket === id;
      });
    } else {
      index = findIndex(timers, function (timer) {
        return timer.id === id;
      });
    }

    if (index >= 0) {
      var timer = timers[index];
      timers.splice(index, 1);
      return timer;
    }
  };

  /**
   * @private
   * @param {object} timer
   * @returns {number}
   */
  Log.prototype.$getElapsedTime = function (timer) {
    return +new Date() - timer.start;
  };

  /**
   * Logs the given message with level `CONFIG`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.config = function (msg, args) {
    return doLog(this, Level.CONFIG, arguments);
  };

  /**
   * Logs the given message with level `FINE`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.fine = function (msg, args) {
    return doLog(this, Level.FINE, arguments);
  };

  /**
   * Logs the given message with level `FINER`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.finer = function (msg, args) {
    return doLog(this, Level.FINER, arguments);
  };

  /**
   * Logs the given message with level `FINEST`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.finest = function (msg, args) {
    return doLog(this, Level.FINEST, arguments);
  };

  /**
   * Logs the given message with level `INFO`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.info = function (msg, args) {
    return doLog(this, Level.INFO, arguments);
  };

  /**
   * Logs the given message with level `SEVERE`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.severe = function (msg, args) {
    return doLog(this, Level.SEVERE, arguments);
  };

  /**
   * Logs the given message with level `WARNING`.
   * @param {string} msg log message
   * @param {...*} [args] additional arguments to use for parameterization
   * @returns {Promise}
   * @see module:nmodule/js/rc/log/Log#log
   */
  Log.prototype.warning = function (msg, args) {
    return doLog(this, Level.WARNING, arguments);
  };

  /**
   * Sets the logging level configured on this Log instance.
   * @param {module:nmodule/js/rc/log/Log.Level} level
   */
  Log.prototype.setLevel = function (level) {
    this.$level = level;
  };
  
  
  
  function consoleLog(level, args) {
    return Log.logMessage(CONSOLE_LOG_NAME, level,
      slice.apply(args).map(formatArg).join(' '));
  }

  function doLog(log, level, args) {
    return log.log.apply(log, [ level ].concat(slice.call(args)));
  }

  function getLevel(name, logLevels) {
    if (!logLevels || typeof logLevels !== 'object') { return DEFAULT_LEVEL; }

    var split = name.split('.'),
      configuredLogNames = Object.keys(logLevels),
      level = '',
      longestMatch = [];

    for (var i = 0, len = configuredLogNames.length; i < len; ++i) {
      var configName = configuredLogNames[i],
        configSplit = configName ? configName.split('.') : [];
      if (split.slice(0, configSplit.length).join('.') === configName &&
        configSplit.length >= longestMatch.length) {
        level = logLevels[configName];
        longestMatch = configSplit;
      }
    }

    return Log.Level[level] || DEFAULT_LEVEL;
  }

  /**
   * Async version of slf4jFormat in order to localize the error string
   * @param {Error|String} msg the error or string to be sent to the log
   * @param {Object} args any arguments needed in order to format the message
   * @returns {Promise<String>}
   */
  function slf4jFormatAsync(msg, args) {
    if (msg instanceof Error && msg.name === 'LocalizableError' && typeof msg.toStack === 'function') {
      return msg.toStack()
        .then((stack) => {
          return stack;
        });
    }

    return Promise.resolve(slf4jFormat(msg, args));
  }

  /**
   * Determines the error message to be sent to the log file
   * @param {Error|String} msg the error or string that is to be logged
   * @param {Object} args any arguments needed in the formatting of the error
   * @returns {string}
   */
  function slf4jFormat(msg, args) {
    if (msg instanceof Error) {
      return msg.stack || String(msg);
    }

    var i = -1, len = args.length;
    var str = String(msg).replace(/[\\]?[\\]?\{\}/g, function (match) {
      if (match[0] === '\\') {
        if (match[1] === '\\') {
          return '\\' + formatArg(args[++i]);
        } else {
          return '{}';
        }
      } else {
        ++i;
        if (i >= len) {
          return '{}';
        } else {
          return formatArg(args[i]);
        }
      }
    });

    //check for trailing Error arg and log it by itself
    if (i === len - 2) {
      var err = args[len - 1];
      if (err instanceof Error) {
        str += '\n' + err.stack;
      }
    }

    return str;
  }

  function formatArg(obj) {
    if (isFunction(obj)) {
      return formatFunction(obj);
    }

    if (typeof obj === 'object' && obj &&
      obj.toString === Object.prototype.toString) {
      try {
        return JSON.stringify(obj, (k, v) => {
          if (isFunction(v)) {
            return formatFunction(v);
          }
          return v;
        });
      } catch (e) {
        Log.error(e);
        return '{invalid JSON}';
      }
    }

    if (Array.isArray(obj)) {
      return '[' + obj.map(formatArg).join(',') + ']';
    }

    return String(obj);
  }

  function formatFunction(f) {
    return '<function ' + f.name + '()>';
  }

  Log.formatArg = formatArg;
  Log.Level = Level;

  return Log;
});