logtown/logtown

View on GitHub
packages/logger/index.js

Summary

Maintainability
A
1 hr
Test Coverage
'use strict';

import get from 'dlv';
import set from 'dset';
import deepmerge from 'deepmerge';
import isPlainObject from 'is-plain-object';

const deepMergeOptions = {
  isMergeableObject: (obj) => isPlainObject(obj) || Array.isArray(obj),
};

const loggers = {};
let configRegistry = {};
const wrappers = [];
const pluginsRegistry = [];
const LEVELS = Object.freeze({ SILLY: 'SILLY', DEBUG: 'DEBUG', INFO: 'INFO', WARN: 'WARN', ERROR: 'ERROR' });
const WRAPPER_OPTS_SYMBOL = Symbol();
const WRAPPER_OPTS_KEY = 'logOptions';
const WRAPPER_PLUGINS_KEY = 'logPlugins';

function buildMessageOptions(id) {
  const { namespaces, disable, ...confs } = configRegistry;
  let options = deepmerge.all([
    {},
    get(configRegistry, `namespaces.${id}`, {}),
    { disable: get(configRegistry, 'disable', []) },
    confs,
    { disable: configRegistry.useGlobal ? get(global, `logtown.namespaces.${id}.disable`, []) : [] }
  ], deepMergeOptions);
  options.disable = options.disable.map(d => d.toUpperCase());
  return options;
}

function equalsWrapperOptValue(wrapper, opt, value) {
  return get(wrapper, ['constructor', WRAPPER_OPTS_KEY, opt]) === value || get(wrapper, [WRAPPER_OPTS_SYMBOL, opt]) === value;
}

function extractWrapperPlugins(wrapper) {
  return get(wrapper, ['constructor', WRAPPER_PLUGINS_KEY]) || get(wrapper, [WRAPPER_OPTS_SYMBOL, 'plugins']);
}

/**
 * Send log message to all available wrappers
 *
 * @param {string} id
 * @param {string} level
 * @param {boolean} fallbackToLog
 * @param {[]} rest
 */
function sendMessage(id, level, fallbackToLog, ...rest) {
  const levelMethod = level.toLowerCase();
  const options = buildMessageOptions(id);
  const tagsToDisable = get(configRegistry, 'tags.disable', []);
  const namespaceTags = get(configRegistry, `namespaces.${id}.tags`, []);
  const containsDisabledTag = tagsToDisable.some((element) => {
    return namespaceTags.indexOf(element) > -1;
  });

  if (options.disable.indexOf(level) > -1 ||
    containsDisabledTag) {
    // if global or logger's setting is saying to disable logging, proceed silently
    if (!!configRegistry.verbose) {
      console.log(`Level ${level} or namespace ID ${id} has been disabled. skipping...`);
    }
    return;
  }

  const [, , stats, ...args] = applyPlugins(pluginsRegistry, id, level, calcStats(), ...rest);

  wrappers.concat(options.wrappers)
    .forEach((wrapper) => {
      const restOrArgs = equalsWrapperOptValue(wrapper, 'passInitialArguments', true) ? rest : args;
      const plugins = extractWrapperPlugins(wrapper);
      const [, , wrapperStats, ...wrapperArgs] = applyPlugins(plugins, id, level, stats, ...restOrArgs);

      if (typeof wrapper[levelMethod] === 'function') {
        // use specific logging method if exists, for example, wrapper.info()
        return wrapper[levelMethod](id, wrapperStats, ...wrapperArgs);
      } else if (typeof wrapper.log === 'function' && !!fallbackToLog) {
        // use generic log method, if fallbackToLog === true. It is always equal to TRUE for standard levels
        return wrapper.log(id, level, wrapperStats, ...wrapperArgs);
      }
      if (!!configRegistry.verbose) {
        console.log(`Wrapper has no valid logging method. fallbackToLog is equal ${fallbackToLog}. skipping...`);
      }
    });
}

/**
 * Prepare stats. Currently only maxIdLength supported in core, can be extended
 * in custom plugin.
 *
 * @return {{maxIdLength: number}}
 */
function calcStats() {
  return {
    maxIdLength: Math.max(...Object.keys(loggers).map((l) => l.length))
  };
}

/**
 * Run all plugins. Must return always non empty array with [id, level, stats, ...rest]
 *
 * @param {[]} plugins
 * @param {string} id
 * @param {string} level
 * @param {{}} stats
 * @param {*} rest
 * @return {[]}
 */
function applyPlugins(plugins = [], id, level, stats, ...rest) {
  if (!Array.isArray(plugins) || !plugins.every((p) => typeof p === 'function')) {
    throw new Error('Plugins MUST be an array of functions');
  }
  const args = Object.freeze(rest.map((arg) => Object.freeze(arg)));
  // mutable context object that passes into plugin function and
  // should be modified with new args or stats
  // id, level and initial arguments array MUST not be modified
  const ctx = {
    get id() {
      return id
    },
    get level() {
      return level
    },
    get arguments() {
      return args
    },
    stats,
    args: rest
  };

  plugins.forEach((pluginFn) => {
    pluginFn(ctx);
  });
  const resArgs = Array.isArray(ctx.args) && ctx.args.length ? ctx.args : ctx.arguments;
  return [id, level, ctx.stats, ...resArgs];
}

/**
 * Create new logger instance
 *
 * @param {string} id
 * @return {{id, silly: (function(...[*])), debug: (function(...[*])), info: (function(...[*])), warn: (function(...[*])), error: (function(...[*]))}}
 */
function createLogger(id) {
  let log = (level, fallbackToLog, ...rest) => sendMessage(id, level, fallbackToLog, ...rest);
  return {
    get id() {
      return id;
    },
    silly(...rest) {
      log(LEVELS.SILLY, true, ...rest);
    },
    debug(...rest) {
      log(LEVELS.DEBUG, true, ...rest);
    },
    info(...rest) {
      log(LEVELS.INFO, true, ...rest);
    },
    warn(...rest) {
      log(LEVELS.WARN, true, ...rest);
    },
    error(...rest) {
      log(LEVELS.ERROR, true, ...rest);
    },
    // CAUTION: experimental feature
    send(level, ...rest) {
      log(level, false, ...rest);
    }
  }
}

/**
 * Returns array if not array is passed to the function
 *
 * @param {[]|any} value
 * @return {[]}
 */
function normalizeArray(value = []) {
  if (Array.isArray(value)) {
    return value;
  }
  return [value];
}

/**
 * Get logger that exits or create new one
 *
 * @param {String} id
 * @param {[]} disable
 * @param {[]} wrappers
 * @param {[]} tags
 * @returns {{silly: Function, debug: Function, info: Function, warn: Function, error: Function}}
 */
function getLogger(id, { disable = [], wrappers = [], tags = [] } = {}) {
  let config = {
    disable: normalizeArray(disable).map(v => v + ''),
    wrappers: normalizeArray(wrappers),
    tags: normalizeArray(tags)
  };

  set(configRegistry, `namespaces.${id}`, deepmerge(get(configRegistry, `namespaces.${id}`, {}), config, deepMergeOptions));

  return loggers[id] || (loggers[id] = createLogger(id));
}

/**
 * Add global configs
 *
 * @param {boolean} useGlobal
 * @param {[]} disable
 * @param {{}} namespaces
 * @param {{}} tags
 * @param {boolean} verbose
 * @param {boolean} override
 */
function configure({ useGlobal = true, disable = [], namespaces = {}, tags = {}, verbose = false } = {}, override = false) {
  const config = Object.create(null);
  config.useGlobal = !!useGlobal;
  config.disable = normalizeArray(disable).map(v => v + '');
  config.namespaces = namespaces;
  config.tags = {
    disable: normalizeArray(get(tags, 'disable', []))
  };
  config.verbose = verbose;
  configRegistry = override ? config : deepmerge(configRegistry, config, deepMergeOptions);
}

/**
 * Add new wrapper. Can handle any object with one of methods from array:
 * ['log', 'silly', 'debug', 'info', 'warn', 'error']
 *
 * @param {{log?: Function, silly?: Function, debug?: Function, info?: Function, warn?: Function, error?: Function}|Function} wrapper
 * @param {{}} opts
 */
function addWrapper(wrapper, { passInitialArguments = false, plugins = [] } = {}) {
  const opts = {
    passInitialArguments,
    plugins: normalizeArray(plugins)
  };
  if (
    typeof wrapper === 'object' && !Array.isArray(wrapper)
    &&
    (
      typeof wrapper.log === 'function' ||
      Object.keys(LEVELS).some((lvl) => typeof wrapper[lvl.toLowerCase()] === 'function')
    )
  ) {
    // if wrapper is instance of some class or pure object, it must include at least one method with level name
    wrapper[WRAPPER_OPTS_SYMBOL] = opts;
    wrappers.push(wrapper);
    return;
  }
  if (typeof wrapper === 'function') {
    const wrapperObject = {
      log: wrapper,
      [WRAPPER_OPTS_SYMBOL]: opts
    };
    wrappers.push(wrapperObject);
    return;
  }
  throw new Error('Wrapper did not implemented a minimum methods required');
}

/**
 * Add plugin function. Plugin function must return array with log argument values.
 */
function addPlugin(useLevel, fn) {
  let pluginFn = fn;
  if (typeof fn === 'undefined' && typeof useLevel === 'function') {
    pluginFn = useLevel;
  }

  if (typeof pluginFn !== 'function') {
    throw new Error('Plugin must be a function!');
  }

  if (typeof useLevel === 'string') {
    pluginFn = (id, level, stats, ...rest) => {
      if (level === useLevel.toUpperCase()) {
        return fn(id, level, stats, ...rest);
      }
      return [id, level, stats, ...rest];
    };
  }

  pluginsRegistry.push(pluginFn);
}

function cleanPlugins() {
  pluginsRegistry.splice(0, pluginsRegistry.length);
}

function cleanWrappers() {
  wrappers.splice(0, wrappers.length);
}

/**
 * Clean wrappers, plugins and configurations
 */
function cleanAll() {
  cleanWrappers();
  cleanPlugins();
  configure({}, true);
}

/**
 * Factory method which returns logger. alias to getLogger()
 *
 * @param {string} id
 * @param {[]} disable
 * @param {[]} wrappers
 * @returns {{silly: Function, debug: Function, info: Function, warn: Function, error: Function}}
 */
const factory = (...args) => {
  return getLogger(...args);
};

factory.getLogger = getLogger;
factory.configure = configure;
factory.addWrapper = addWrapper;
factory.addPlugin = addPlugin;
factory.LEVELS = LEVELS;
factory.cleanAll = cleanAll;
factory.cleanWrappers = cleanWrappers;
factory.cleanPlugins = cleanPlugins;

/**
 * @type {{getLogger: ((id:String, config?:{disable, wrappers})=>{silly, debug, info, warn, error}), configure: ((config?)), addWrapper: ((wrapper?)), LEVELS: Object}}
 */
export default factory;