yoctore/yocto-logger

View on GitHub
src/index.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict';

const winston       = require('winston');
const _             = require('lodash');
const chalk         = require('chalk');
const moment        = require('moment');
const fs            = require('fs');
const path          = require('path');
const stackTrace    = require('stack-trace');
const morgan        = require('morgan');
const os            = require('os');

// To this require to extend main winston var
require('winston-daily-rotate-file');

/**
 *
 * This class manage all method to add or use a logger instance on your app.
 *
 * This module is based on <b><a class="blank" href="https://github.com/winstonjs/winston">winston</a></b>
 * for default logging and <b><a class="blank" href="https://github.com/expressjs/morgan">morgan</a></b> for request access logging.
 *
 * This module is not a complete re-implementation of winston or morgan, we only use needed functionnality on this two module to create your own.
 *
 * With this module we can create log on <code class="code">Console</code> and <code class="code">File</code> with a daily rotate management.
 *
 * This module use syslog levels for logging action for more details see <b><a class="blank" href="https://github.com/winstonjs/winston#logging-levels">here</a></b>
 *
 * @example
 * // require the module
 * const logger = require('yocto-logger');
 *
 * // create your logger
 * const log = logger.create();
 *
 * // At this point just use your logger
 *
 * @constructor
 */
class Logger {
  /**
   * Default constructor of Logger class.
   *
   * Don't use it directly, prefer use <code class="code">logger.create()</code> instead
   *
   * @constructor
   */
  constructor () {
    /**
     * Lists of logger levels custom for your own usage
     *
     * @memberof Logger
     * @member {Array} levels lists of remap syslog levels for current app
     */
    this.levels = _.map(winston.config.syslog.levels, function (level, key) {
      // Default statement
      return {
        level       : level,
        fn          : key,
        name        : key.replace(/^crit$/g, 'critical').replace(/^emerg$/g, 'emergency'),
        useCallback : level >= 0 && level < 3
      }
    });

    /**
     * Default log level set to use app.
     *
     * If <code>process.env.NODE_ENV</code> is set to <code>production</code> the <code>notice</code> level was used by default,
     * otherwise the <code>debug</code> level is used
     *
     * @memberof Logger
     * @member {Integer} logLevel default log level for current app
     */
    this.logLevel = _.find(this.levels,
      [ 'name', process.env.NODE_ENV === 'production' ? 'notice' : 'debug' ]);

    /**
     * Default transformer use by current logger
     *
     * @memberof Logger
     * @member {FormatWrap} defaultTransformer internal default transformer
     */
    this.defaultTransformer = winston.format(function (info) {
      // Replace some %s to "%s" for display formating
      if (_.isString(info.message)) {
        info.message = info.message.replace(/%s/g, '"%s"');
      }

      // Try to get stack trace to buld format
      const stack  = stackTrace.get();
      const caller = _.size(stack) >= 22 ? stack[22] : null;

      // Has specified method ?
      if (_.isFunction(caller.getMethodName) && _.isFunction(caller.getFileName)) {
        // Build default from
        info.from = caller.getMethodName() || caller.getFileName();

        // Remove working directory path to avoid long path in log
        info.from = info.from.replace(process.cwd(), '');

        // Typeame is a specific class ?
        if (!path.extname(info.from) && !_.includes(caller.getTypeName(), [ 'Object' ])) {
          // Append full path on log
          info.from = [ caller.getTypeName(), info.from ].join('.');
        }

        // Add line number to from
        info.from = [ info.from, caller.getLineNumber() ].join(':');
      }

      // Set level to uppercase and padded properly
      info.level = _.padEnd(info.level.toUpperCase(), 7, ' ');

      // Default statement
      return info;
    });

    /**
     * Default timestamp transformer use by current logger
     *
     * @memberof Logger
     * @member {FormatWrap} defaultTimestampFormat internal default transformer for timestamp
     */
    this.defaultTimestampFormat = winston.format(info =>{
      // Set locale with default system locale
      moment.locale(process.env.LC_ALL || process.env.LC_MESSAGES ||
                    process.env.LANG || process.env.LANGUAGE);

      // Set default timestamp format
      info.timestamp = moment(info.timestamp).format('L LTS');

      // Default statement
      return info;
    });

    /**
     * Define here default print transformer to display data like we need
     *
     * @memberof Logger
     * @member {FormatWrap} defaultPrintTranformer internal default print transformer
     */
    this.defaultPrintTranformer = winston.format.printf(info =>{
      // In case a label is defined
      if (!_.isUndefined(info.label)) {
        // Default statement
        return `[${info.timestamp}] -> ${info.level} - [${info.label}] : ${info.message}`;
      }

      // Default statement
      return `[${info.timestamp}] | ${info.level} -> [${info.from}] : ${info.message}`;
    });

    /**
     * Define here default print transformer for web access to display data like we need
     *
     * @memberof Logger
     * @member {FormatWrap} defaultWebPrintTranformer internal default print transformer for web request
     */
    this.defaultWebPrintTranformer = winston.format.printf(info =>info.message.replace(os.EOL, ''));

    /**
     * Default winston instance logger
     *
     * @memberof Logger
     * @member {Instance} winston internal winston instance
     */
    this.winston = winston.createLogger({
      transports : [
        new winston.transports.Console({
          handleExceptions : true,
          format           : winston.format.combine(
            this.defaultTransformer(),
            winston.format.timestamp(),
            this.defaultTimestampFormat(),
            winston.format.label(),
            winston.format.colorize(),
            winston.format.splat(),
            this.defaultPrintTranformer
          )
        })
      ],
      silent : false,
      levels : winston.config.syslog.levels,
      level  : this.logLevel.name
    });

    /**
     * Default morgan web logger instance
     *
     * @memberof Logger
     * @member {Instance} webLogger internal morgan instance
     */
    this.webLogger = winston.createLogger();
  }

  /**
   * Default method to create a logger instance.
   *
   * Use this method to get default logger instance
   *
   * @example
   * var logger = require('yocto-logger');
   * // Create your own logger here
   * logger = logger.create();
   *
   * @return {Logger} an instance of current logger
   */
  static create () {
    // New logger instance
    return new Logger();
  }

  /**
   * Enable log on console
   *
   * @param {Boolean} status true if we need to enable false otherwise
   * @return {Boolean} true in case of success false otherwise
   */
  enableConsole (status) {
    // Has a valid status
    status = _.isBoolean(status) ? status : true;

    // Parse all item
    _.each(this.winston.transports, transport =>{
      // Only in case of Console
      if (transport instanceof winston.transports.Console) {
        // Change transport silent status
        transport.silent = !status;
      }
    });

    // Default statement
    return false;
  }

  /**
   * Disable log on console
   *
   * @return {Boolean} true in case of success false otherwise
   */
  disableConsole () {
    // Log message
    this.info('[ Logger.disableConsole ] - Disabling console transport');

    // Do main process
    return this.enableConsole(false);
  }

  /**
   * Enable catching error on a daily rotate files
   *
   * @param {Object} options options to use for new transporter
   * @return {Boolean|Mixed} false in case of error otherwise winston.add return value
   */
  enableErrorToDailyRotateFiles (options = {}) {
    // Add default options
    options.extname = 'error';
    options.level = 'warning';
    options.canChangeLevel = false;

    // Default statement
    return this.addDailyRotateTransport(options);
  }

  /**
   * Enable morgan web logger and stream content to a rotate access log file
   *
   * @param {Object} options options to use on daily logger
   * @return {Boolean|Mixed} morgan instance in case of success or false in case of error
   */
  enableRequestToDailyRotateFiles (options = {}) {
    // Add default options
    options.extname = 'access';
    options.level = 'info';
    options.canChangeLevel = false;
    options.xheaders = options.xheaders || [];

    // Create a daily rotate file first
    if (this.addDailyRotateTransport(options, true)) {
      // Create a default stream to use with morgan
      this.webLogger.stream = {
        write : message =>{
          // Call web logger
          this.webLogger.info(message);
        }
      }

      // Default format to use
      const format = !options.xheaders ?
        'combined' : [ morgan['combined'], '- :xheaders' ].join(' ');

      // In some case we need to enable extra token on log
      if (options.xheaders) {
        // Build a dynamic token
        morgan.token('xheaders', function (req) {
          // Build xheader values
          const xheaders = _.compact(_.map(req.headers, (header, key) =>{
            // Current key is on defined list ?
            if (_.includes(options.xheaders, key)) {
              // Default statement
              return [ '(', key, ') ', header ].join('');
            }

            // In other case return invalid statement
            return false;
          }));

          // Default statement is we dont have token
          return xheaders.join(' - ');
        });
      }

      // Default statement
      return morgan(format, {
        stream : this.webLogger.stream
      });
    }

    // Default statement
    return false;
  }

  /**
   * Add a new daily transport file to logger module
   *
   * @example
   * var logger = require('yocto-logger');
   *  // Create your own logger here
   * logger = logger.create(); // Use it
   *
   * // default options
   * var options = {
   *  changeChangeLevel : true, // indicates that dynamic level change is allowed
   *  extname : 'combined', // indicated the extention name of file by default <pattern>-<extname>.log
   *  destination : '.', // default destination of log file
   *  filename : '<pattern>-<extname>.log', // default file name with define structure <pattern>-<extname>.log
   *  pattern : YYYYMMDD, // default pattern of date for filename
   *  zipped : true, // define if log must be zipped
   *  size : '20m', // default size of file
   *  delay : '14d', // default delay of storage
   *  level :  'debug' // default log level
   * };
   *
   * // add your daily rorate file
   * logger.addDailyRotateTransport(options);
   *
   * @param {Object} options override options value on daily rotate
   * @param {Object} isWeb true if is for a web request logger (morgan) false otherwie
   * @return {Boolean|Mixed} false in case of error otherwise winston.add return value
   */
  addDailyRotateTransport (options = {}, isWeb = false) {
    // Try to normalize options
    _.set(options, 'changeChangeLevel', options.canChangeLevel || true);
    _.set(options, 'extname', options.extname || 'combined');
    _.set(options, 'destination',
      path.resolve((options.destination || '.').replace(/\/+/g, '/').replace(/\/$/, '')));
    _.set(options, 'filename', [
      _.compact([ '%DATE%', options.filename || '', options.extname ]).join('-'), 'log'
    ].join('.'));
    _.set(options, 'pattern', options.pattern || 'YYYYMMDD');
    _.set(options, 'zipped', options.zipped || true);
    _.set(options, 'size', options.size || '20m');
    _.set(options, 'delay', options.delay || '14d');
    _.set(options, 'level', options.level || 'debug');

    // Default try catch
    try {
      // Need to check if given path is writable
      if (fs.lstatSync(options.destination).isDirectory()) {
        // Add new daily rotate file
        const transport = new winston.transports.DailyRotateFile({
          dirname          : options.destination,
          filename         : options.filename,
          datePattern      : options.pattern,
          zippedArchive    : options.zipped,
          maxSize          : options.size,
          maxFiles         : options.delay,
          handleExceptions : !isWeb,
          colorize         : false,
          level            : options.level,
          canChangeLevel   : options.changeChangeLevel,
          format           : winston.format.combine(
            this.defaultTransformer(),
            winston.format.timestamp(),
            this.defaultTimestampFormat(),
            winston.format.label(),
            winston.format.splat(),
            isWeb ? this.defaultWebPrintTranformer : this.defaultPrintTranformer
          )
        });

        // In case of web Logger needed
        if (isWeb) {
          // Add to the web logrer
          return this.webLogger.add(transport);
        }

        // Add to the default logger
        return this.winston.add(transport);
      }

      // Do a warning message in this case
      this.warning('Cannot create daily rotate file log handler. Path %s is not a directory.',
        options.destination);
    } catch (e) {
      // To an error message in this case
      this.error('Cannot add a new daily rotate file : %s', e.message);
    }

    // Default statement
    return false;
  }

  /**
   * Change log level manually
   *
   * @param {String} name name of level
   */
  changeLogLevel (name) {
    // Search data
    const search   = _.find(this.levels, [ 'name', name ]);

    // Parse all transport and update value
    _.each(this.winston.transports, function (transport) {
      // Only in this case
      if (transport.name === 'console') {
        // And if can change level is set
        if (_.has(transport, 'options.canChangeLevel') &&
          _.get(transport, 'options.canChangeLevel')) {
          // Get previous value for log message
          transport.previousLevel = transport.level || transport.parent.level;

          // Update level of each transport
          transport.level = !_.isUndefined(search) ? search.name : null;

          // Do a notice message
          this.notice('Level change from %s to %s form transport %s',
            transport.previousLevel, transport.level, transport.name);
        }
      }
    }.bind(this));

    // In case of undefined we set each level to null to keep parent level do his job
    // but we notify on console after the process
    if (_.isUndefined(search)) {
      // Log a notice message
      this.notice('Invalid level value. The defaut value %s was used.', this.winston.level);
    }
  }

  /**
   * Default log function. This method call winston logger with correct level of logs.
   *
   * Specific transport can be used in last param to use a specific transport.
   *
   * @param {Integer} level level to use on current log level
   * @param {String} message default message to display
   * @param {Object} meta default meta to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  process (level, message, meta) {
    // Try to find correct level
    level = _.find(this.levels, [ 'name', level ]);

    // Level allow callback usage ?
    if (level.useCallback) {
      // Is a callback if defined call this with message and level
      if (this.callback && _.isFunction(this.callback)) {
        // Default callback action
        return callback(level, message, meta);
      }
    }

    // Tranform data to string
    if (_.isObject(message) || _.isArray(message)) {
      message = JSON.stringify(message);
    }

    // Default log process
    return this.winston.log(level.fn, message, ... meta);
  }

  /**
   * Log message and metadata with the current emergency level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  emergency (message, ... meta) {
    // Call main log process with emergency level
    return this.process('emergency', message, meta);
  }

  /**
   * Log message and metadata with the current alert level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  alert (message, ... meta) {
    // Call main log process with alert level
    return this.process('alert', message, meta);
  }

  /**
   * Log message and metadata with the current critical level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  critical (message, ... meta) {
    // Call main log process with critical level
    return this.process('critical', message, meta);
  }

  /**
   * Log message and metadata with the current error level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  error (message, ... meta) {
    // Call main log process with error level
    return this.process('error', message, meta);
  }

  /**
   * Log message and metadata with the current warning level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  warning (message, ... meta) {
    // Call main log process with warning level
    return this.process('warning', message, meta);
  }

  /**
   * Log message and metadata with the current notice level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  notice (message, ... meta) {
    // Call main log process with notice level
    return this.process('notice', message, meta);
  }

  /**
   * Log message and metadata with the current info level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  info (message, ... meta) {
    // Call main log process with info level
    return this.process('info', message, meta);
  }

  /**
   * Log message and metadata with the current debug level
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  debug (message, ... meta) {
    // Call main log process with debug level
    return this.process('debug', message, meta);
  }

  /**
   * Log a banner message on console
   *
   * @param {String} message message to send on logger
   * @param {Object} meta metadata to send on logger
   * @return {DerivedLogger} instance of derived logger
   */
  banner (message, ... meta) {
    // Default step value
    const step = 20;

    // Define separtor element
    const separator = {
      horizontal : '-',
      begin      : '|',
      end        : '|'
    };

    // Only if message is define

    if (message) {
      // Format banner message before send to notice method
      const line = [ _.repeat(separator.horizontal, _.size(message) + step * 2) ].join('');

      // Print first line

      if (this.process('info', line, meta)) {
        // Format message
        message = [ separator.begin,
          _.repeat(' ', (step * 2 - 2) / 2),
          _.toUpper(message),
          _.repeat(' ', (step * 2 - 2) / 2),
          separator.end
        ].join('');

        // Default message print
        if (this.process('info', message, meta)) {
          // Print last line
          return this.process('info', line, meta);
        }
      }
    }

    // Default statement
    return this.process('info', message || '', meta);
  }

  /**
   * Utility message to provide deprectated log format
   *
   * @param {String} sourceMethod default deprectated source method
   * @param {String} newMethod new method name to use
   * @param {String} extraMessage an extra message to add to current log
   * @return {DerivedLogger} instance of derived logger
   */
  deprecated (sourceMethod, newMethod, extraMessage = '') {
    // Default statement
    return this.process('notice', [
      chalk.yellow('[DEPRECATED]'), `Method %s is depreacted. Prefere use %s. ${extraMessage}`
    ].join(' '), [ sourceMethod, newMethod ]);
  }
}

/**
 * Export current logger to use it on node
 */
module.exports = Logger;