index.js

/* eslint-disable no-promise-executor-return */
/* eslint-disable no-plusplus */
/* eslint-disable no-param-reassign */
/* eslint-disable-next-line max-classes-per-file */
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const util = require('util');

const ansiRegex = require('ansi-regex'); // version 6 requires ESM so version 5 is used
const deepCleaner = require('deep-cleaner');
const hostId = require('hostid');
const humanizeDuration = require('humanize-duration');
const Joi = require('joi');
const prune = require('json-prune');
const { ulid } = require('ulidx');

// Winston3
const winston = require('winston');
require('winston-daily-rotate-file'); // This looks weird but it's correct
const { consoleFormat: WinstonConsoleFormat } = require('winston-console-format');

// Local includes
const Stack = require('./Stack');

// Load my package.json instead of the one for this module's user
const { name: myName, version: myVersion } = require('./package.json'); // Discard the rest

// =================================================================================================
// Developer Notes
// =================================================================================================
// 1. typeof(null) === 'object'. Use instanceof Object instead.
// 2. This code uses 'in' instead of Object.keys() because protoptype fields
//    are useful to log
//
// TODO
// 1. Find eslint-disable-next-line no-restricted-syntax and replace with forEach

const addErrorSymbol = Symbol.for('error');
const { format } = winston;
const transportNames = ['file', 'errorFile', 'cloudWatch', 'console'];
const nonenumerableKeys = ['message', 'stack'];

// TODO Submit feature request. See cwTransportShortCircuit
// InvalidParameterException is thrown when the formatter provided to winston-cloudwatch returns false
const ignoreCloudWatchErrors = ['ThrottlingException', 'DataAlreadyAcceptedException', 'InvalidParameterException'];

/**
 * Property names in object passed to transformArgs
 * @ignore
 */
const transformArgsProperties = {
  tags: undefined,
  message: undefined,
  data: undefined,
  context: undefined,
  category: undefined,
};

/**
 * Removes internal functions from the stack trace. This only works for code that uses this module. It
 * doesn't work for unit tests.
 * @ignore
 */
const stripStack = /\s+at [^(]+\(.*[/|\\](?:(?:@goodware[/|\\]log)|winston|logform)[/|\\][^)]+\)\n?/g;

/**
 * Used for tag filtering
 * @ignore
 */
const transportObj = {};
transportNames.forEach((transport) => {
  transportObj[transport] = null;
});

/**
 * Category names for internal loggers
 * @ignore
 */
const reservedCategories = {
  unhandled: '@goodware/unhandled',
  cloudWatch: '@goodware/cloudwatch',
  log: '@goodware/log', // When the API is misused
};

// ================
// Module variables
let ansiRegexEngine;
let WinstonCloudWatch;
let noCloudWatch;

/**
 * Internal class for identifying the return value of transformArgs()
 * @ignore
 */
class LogArgs {}

/**
 * Internal class for identifying log entries that are created by Loggers::logEntry
 * @ignore
 */
class LogEntry {}

/**
 * Internal class for identifying objects returned by context
 * @ignore
 */
class Context {}

/**
 * Manages logger objects that can send log entries to the console, files, and AWS CloudWatch Logs
 */
class Loggers extends EventEmitter {
  /**
   * Private Properties
   *  {object} options
   *  {object} unitTest
   *  {boolean} props.starting
   *  {boolean} props.stopping
   *  {boolean} props.stopped
   *  {number} props.restarting
   *  {string} props.created
   *  {string} props.hostId
   *  {object[]} props.loggers
   *  {string[]} props.metaProperties
   *  {object} props.meta Has properties with the same name as the metaProperties
   *  {function} props.unhandledPromiseListener
   *  {function[]} props.stopWaiters
   *  {string[]} props.levels {string} with 'default'
   *  {object} props.logStackLevels
   *  {object} props.winstonLoggers {string} category -> Winston logger
   *  {object} props.userMeta {string} metaFieldName -> undefined. Added to new LogEntry objects.
   *  {string[]} props.redact keys to nonrecursively remove from data
   *  {string[]} props.recursiveRedact keys to recursively remove from data
   *  {string} props.cloudWatchStream
   *  {object[]} props.cloudWatchTransports
   *  {object} props.categoryTags {string} category -> {{string} tag -> {object}}
   *  {object} props.hasCategoryTags {string} category -> {boolean}
   *  {object} props.levelSeverity {string} level plus 'on', 'off', and 'default'
   *   -> {Number} {object} winstonLevels Passed to Winston when creating a logger
   *  {object} props.loggerStacks
   *
   * Notes to Maintainers
   *  1. tags, message, and data provided to public methods should never be modified
   *  2. The output of Object.keys and Object.entries should be cached for static objects
   *  3. 'in' is used with caller-supplied objects instead of Object.keys() or Object.entries() in order to work with
   *     parent classes since keys() and entries() returns 'own' properties only
   *
   * @todo
   * 1. When console data is requested but colors are disabled, output data without colors using a new formatter
   * 2. Add a new metatag to output to the plain console
   * 3. Document defaultTagAllowLevel
   * 4. Custom levels and colors RunKit example
   * 5. Move Logger to another module - see
   *    https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de
   */

  /**
   * @constructor
   * @param {object} options
   * @param {object} [levels] An object with properties levels and colors, both of which are objects whose keys are
   * level names. This is the same object that is provided when creating Winston loggers. See an example at
   * https://www.npmjs.com/package/winston#using-custom-logging-levels
   */
  constructor(options, levels = Loggers.defaultLevels) {
    super();

    if (!ansiRegexEngine) ansiRegexEngine = ansiRegex();

    const props = {
      stopped: true,
      restarting: 0,
      // levels must be set before validating options
      levels: Object.keys(levels.levels),
      created: Loggers.now(),
      hostId: hostId(),
      loggers: {},
      winstonLoggers: {},
      meta: { message: 'message', stack: 'stack' },
      userMeta: {},
      categoryTags: {},
      hasCategoryTags: {},
      cloudWatchLogGroups: {},
    };

    this.props = props;

    // =============================================
    // Copy environment variables to options (begin)

    /**
     * Sets options.console.{key} if a CONSOLE_{KEY} environment variable exists
     * @param {string} key 'data' or 'colors'
     * @ignore
     */
    const envToConsoleKey = (key) => {
      const envKey = `CONSOLE_${key.toUpperCase()}`;
      const value = process.env[envKey];
      if (value === undefined) return;
      options.console[key] = value === 'true' ? true : !!Number(value);
    };

    // Copy environment variables to options (end)

    // Validate options
    options = this.validateOptions(options);
    this.options = options;

    envToConsoleKey('colors');
    envToConsoleKey('data');

    // Do not add default until after validating
    this.props.levels.push('default');

    // Level color
    {
      let { colors } = levels;
      const { levelColors } = options;
      if (levelColors) {
        colors = { ...colors };
        Object.entries(levelColors).forEach(([level, color]) => {
          colors[level] = color;
        });
      }

      winston.addColors(colors);
    }

    /**
     * Maps level names to integers where lower values have higher severity
     * @type {object}
     */
    this.winstonLevels = levels.levels;

    this.props.levelSeverity = { ...levels.levels };
    Object.assign(this.props.levelSeverity, {
      off: -1,
      default: this.props.levelSeverity[options.defaultLevel],
      on: 100000,
    });

    // Process meta properties (begin)
    {
      const { meta } = props;

      Object.entries(options.metaProperties).forEach(([key, value]) => {
        if (!value) value = key;
        meta[key] = key;
        props.userMeta[key] = undefined;
      });

      props.metaProperties = Object.keys(meta);
    }
    // Process meta properties (end)

    // Add the default category if it's missing
    {
      const category = options.categories;
      if (!category.default) category.default = {};
    }

    // Process category tag switches
    if (!this.processCategoryTags('default')) props.categoryTags.default = { on: true };

    // Set props.logStackLevels
    {
      const obj = {};
      props.logStackLevels = obj;

      options.logStackLevels.forEach((level) => {
        if (level === 'default') level = options.defaultLevel;
        obj[level] = null;
      });
    }

    // For the default category, convert the settings for each transport from string to object and potentially overwrite
    // corresponding transport settings in the top-level keys such as console
    {
      const { default: defaultConfig } = options.categories;

      if (defaultConfig) {
        Object.entries(defaultConfig).forEach(([key, value]) => {
          if (!(key in transportObj)) return;
          if (!(value instanceof Object)) value = { level: value };
          Object.assign(options[key], value);
        });
      }
    }

    // =====================
    // Preprocess redactions
    this.props.redact = Object.entries(options.redact).reduce((prev, [key, value]) => {
      if (!value.recursive) prev[key] = null;
      return prev;
    }, {});

    this.props.recursiveRedact = Object.entries(options.redact).reduce((prev, [key, value]) => {
      if (value.recursive) prev.push(key);
      return prev;
    }, []);

    // =========================
    // Add logging-level methods
    this.addLevelMethods(this);

    this.start();
  }

  /**
   *
   * @ignore
   * @param {WeakSet} processed
   * @param {object} obj
   * @param {object} result
   */
  static deepCopy(processed, object) {
    processed.add(object);

    // eslint-disable-next-line no-restricted-syntax, guard-for-in
    for (const key in object) {
      let value = object[key];
      if (value instanceof Object && !(value instanceof Array) && !processed.has(value)) {
        if ('stack' in value || 'message' in value) {
          value = { ...value, stack: value.stack, message: value.message };
          object[key] = value;
        }
        Loggers.deepCopy(processed, value);
      }
    }
  }

  /**
   * Determines any of the arguments is an object with an Error instance
   * @param {Array} args
   * @returns {boolean}
   * @ignore
   */
  static hasError(...args) {
    return args.some((arg) => {
      if (arg instanceof Error) return true;
      if (!(arg instanceof Object) || arg instanceof Array) return false;
      // eslint-disable-next-line no-restricted-syntax
      for (const key in arg) if (arg[key] instanceof Error) return true;
      return false;
    });
  }

  /**
   * Determines whether an object has any properties. Faster than Object.keys(object).length.
   * See https://jsperf.com/testing-for-any-keys-in-js
   * @param {object} object An object to test
   * @returns {boolean} true if object has properties (including inherited)
   * @ignore
   */
  static hasProperty(object) {
    // See https://stackoverflow.com/questions/679915/how-do-i-test-for-an-empty-javascript-object
    if (!(object instanceof Object) || object instanceof Array) return false;
    // message and stack are invisible; any others?
    // eslint-disable-next-line no-restricted-syntax, guard-for-in, no-unreachable-loop
    for (const prop in object) return true;
    if ('message' in object || 'stack' in object) return true;
    return false;
  }

  /**
   * Converts a number to a string with leading zeroes
   * @param {Number} num The number to convert
   * @param {Number} size The minimum number of digits
   * @returns {string} num converted to a string with leading zeroes if necessary
   * @ignore
   */
  static pad(num, size = 2) {
    let s = num.toString();
    while (s.length < size) s = `0${s}`;
    return s;
  }

  /**
   * Returns local time in ISO8601 format with the local timezone offset
   * @returns {string}
   * @ignore
   */
  static now() {
    const now = new Date();
    const tzo = now.getTimezoneOffset();

    return `${now.getFullYear()}-${Loggers.pad(now.getMonth() + 1)}-${Loggers.pad(now.getDate())}T${Loggers.pad(
      now.getHours(),
    )}:${Loggers.pad(now.getMinutes())}:${Loggers.pad(now.getSeconds())}.${Loggers.pad(now.getMilliseconds(), 3)}${
      !tzo ? 'Z' : `${(tzo > 0 ? '-' : '+') + Loggers.pad(Math.abs(tzo) / 60)}:${Loggers.pad(tzo % 60)}`
    }`;
  }

  /**
   * Combines tags into a single tags object
   * @param {Array} [args]
   * @returns {object}
   */
  // eslint-disable-next-line class-methods-use-this
  tags(...args) {
    const newTags = {};

    args.forEach((tags) => {
      if (tags instanceof Object) {
        if (tags instanceof Array) {
          tags.forEach((tag) => {
            if (tag) newTags[tag] = true;
          });
        } else {
          Object.assign(newTags, tags);
        }
      } else if (tags) {
        newTags[tags] = true;
      }
    });

    return newTags;
  }

  /**
   * Converts a value to an object
   * @param {*} [data]
   * @param {string} [key] message, data, or context, defaults to context. The key name to use when data is not an
   *   object.
   * @returns {object|undefined}
   * @ignore
   */
  static toObject(data, key = 'context') {
    if (data === undefined) return data;
    if (typeof data === 'function') return undefined;

    if (data instanceof Object && !(data instanceof Array)) {
      if (key === 'data' && data instanceof Error) return { error: data };
      return data;
    }

    return { [key]: data };
  }

  /**
   * @param {string} [level]
   * @param {object} tags
   * @param {string} category
   * @param {*} context
   * @returns {object|undefined}
   * @ignore
   */
  toContext(level, tags, category, context) {
    if (context instanceof Context) return context;
    context = Loggers.toObject(context);

    if (!context) return undefined;

    const event = { level, tags, category, arg: context, type: 'context', property: 'context' };

    try {
      this.emit('redact', event);
      ({ arg: context } = event);
    } catch (error) {
      // eslint-disable-next-line no-console
      console.error('Redact context event handler failed', error);
    }

    if (context instanceof Object && !(context instanceof Array)) {
      // eslint-disable-next-line no-restricted-syntax, guard-for-in
      for (const key in context) {
        event.property = key;
        event.arg = context[key];
        try {
          this.emit('redact', event);
          context[key] = event.arg;
        } catch (error) {
          // eslint-disable-next-line no-console
          console.error('Redact context event handler failed', error);
        }
      }
    }

    // Redact context
    const result = 'message' in context || 'stack' in context ? { message: context.message, stack: context.stack } : {};
    const { redact } = this.props;
    let hasObject;

    // eslint-disable-next-line no-restricted-syntax
    for (const key in context) {
      if (!(key in redact)) {
        const value = context[key];
        if (typeof value !== 'function') result[key] = value;
        if (value instanceof Object) hasObject = true;
      }
    }

    // Recursively reveal objects that have message and stack
    if (hasObject) Loggers.deepCopy(new WeakSet(), result);
    return Loggers.hasProperty(result) ? result : undefined;
  }

  /**
   * Accessor for context
   * @returns {object}
   */
  // eslint-disable-next-line class-methods-use-this
  context() {
    return {};
  }

  /**
   * Combines multiple contexts into one context object
   * @param {string} [level]
   * @param {object} tags
   * @param {string} category
   * @param {Array} args
   * @returns {object}
   * @ignore
   */
  mergeContext(level, tags, category, ...args) {
    let prevCopied;
    let mergedContext = args.reduce((prev, arg) => {
      // toContext performs redaction and emits 'redact' events
      arg = this.toContext(level, tags, category, arg);

      if (!arg) return prev;
      if (!prev) return arg;

      if (!prevCopied) {
        // Make a shallow copy
        prev = { ...prev };
        prevCopied = true;
      }
      return Object.assign(prev, arg);
    }, undefined);

    if (mergedContext === undefined || mergedContext instanceof Context) return mergedContext;

    // =======================================================================
    // Prune data. This unfortunately removes keys that have undefined values.
    mergedContext = JSON.parse(prune(mergedContext, this.options.message.depth, this.options.message.arrayLength));

    {
      const { recursiveRedact } = this.props;
      if (recursiveRedact.length) deepCleaner(mergedContext, recursiveRedact);
    }

    return Object.assign(new Context(), mergedContext);
  }

  /**
   * Adds methods named after levels, such as error()
   * @param target The object to modify
   * @ignore
   */
  addLevelMethods(target) {
    // eslint-disable-next-line no-return-assign
    this.props.levels.forEach((level) => (target[level] = (...args) => Loggers.logLevel(target, level, ...args)));
  }

  /**
   * Processes options
   * @param {object} options
   * @returns {object} options with defaults added
   * @ignore
   */
  validateOptions(options) {
    if (!options) options = {};

    // =============================
    // Joi model for options (begin)
    const levelEnum = Joi.string().valid(...this.props.levels);
    const defaultLevelEnum = Joi.alternatives(levelEnum, Joi.string().valid('default'));
    const offDefaultLevelEnum = Joi.alternatives(defaultLevelEnum, Joi.string().valid('off'));
    const onOffDefaultLevelEnum = Joi.alternatives(offDefaultLevelEnum, Joi.string().valid('on'));

    // Console settings
    const consoleObject = Joi.object({
      level: onOffDefaultLevelEnum,
      colors: Joi.boolean().description('If true, outputs text with ANSI colors to the console').default(true),
      data: Joi.boolean().description('If true, sends context, data, error objects, stack traces, etc. to the console'),
      childErrors: Joi.boolean().default(true).description('If true, logs child error objects'),
    });

    // File settings
    const fileObject = Joi.object({
      level: onOffDefaultLevelEnum,
      directories: Joi.array()
        .items(Joi.string())
        .default(Loggers.defaultFileDirectories)
        .description('Use an empty array for read-only filesystems'),
      datePattern: Joi.string().default('YYYY-MM-DD-HH'),
      utc: Joi.boolean().default(true),
      zippedArchive: Joi.boolean().default(true),
      maxSize: Joi.string().default('20m'),
      maxFiles: Joi.alternatives(Joi.number(), Joi.string()).default('14d')
        .description(`If a number, it is the maximum number of files to keep. If a string, it is the maximum \
age of files to keep in days, followed by the chracter 'd'.`),
    });

    // Region and logGroup are required by winston-cloudwatch but they can be provided under categories
    const cloudWatchObject = Joi.object({
      level: onOffDefaultLevelEnum,
      region: Joi.string(),
      logGroup: Joi.string(),
      uploadRate: Joi.number()
        .integer()
        .min(1)
        .default(2000)
        .description('The frequency in which entries are sent to CloudWatch. Number of milliseconds between flushes.'),
    });

    // Add flushTimeout for the top cloudWatch key only
    const cloudWatchTopObject = cloudWatchObject.keys({
      flushTimeout: Joi.number()
        .integer()
        .min(1)
        .default(90000)
        .description(
          `The maximum number of milliseconds to wait when sending the current batch of log entries to CloudWatch`,
        ),
    });

    // Options provided to the constructor. The defaults in this object assume the following levels exist:
    // error, warn, debug
    const optionsSchema = Joi.object({
      // Process-related meta
      stage: Joi.string().description('Added as a meta property if provided'),
      service: Joi.string().description('Added as a meta property if provided'),
      version: Joi.string().description('Added as a meta property if provided'),
      commitSha: Joi.string().description('Added as a meta property if provided'),

      // Defaults
      defaultCategory: Joi.string().default('general'),
      defaultLevel: levelEnum.default('debug').description('Which level to use when a level is not found in tags'),
      defaultTagAllowLevel: offDefaultLevelEnum.default('warn'),

      // Colors
      levelColors: Joi.object().pattern(levelEnum, Joi.string().required()),

      // Set the 'stack' meta with the current call stack
      logStackLevels: Joi.array().items(defaultLevelEnum).default(['error']),

      // meta properties
      metaProperties: Joi.object()
        .pattern(Joi.string(), Joi.string().allow(null)) // They can be renamed
        .default(Loggers.defaultMetaProperties)
        .description(`Which properties to copy from context and data the option to rename properties`),

      // Redaction
      redact: Joi.object()
        .pattern(
          Joi.string(),
          Joi.object({
            recursive: Joi.boolean().default(true),
          }).default({}),
        )
        .default({}),

      // Errors
      errors: Joi.object({
        depth: Joi.number()
          .integer()
          .min(1)
          .default(5)
          .description(
            'Errors reference other errors, creating a graph. This is the maximum error graph depth to traverse.',
          ),
        max: Joi.number()
          .integer()
          .min(1)
          .default(20)
          .description(
            'Errors reference other errors. This is the maximum number of errors to log when logging one message.',
          ),
      }).default({}),

      message: Joi.object({
        // Converting objects to strings
        arrayLength: Joi.number()
          .integer()
          .min(1)
          .default(20)
          .description('The maximum number of elements to process when converting an array to a string'),
        depth: Joi.number()
          .integer()
          .min(1)
          .default(20)
          .description('The maximum depth to traverse when converting an object to a string'),
      }).default({}),

      // Turn console status messages on and off
      say: Joi.object({
        flushed: Joi.boolean().default(true),
        flushing: Joi.boolean().default(true),
        ready: Joi.boolean().default(true),
        stopping: Joi.boolean().default(true),
        stopped: Joi.boolean().default(true),
        cloudWatch: Joi.boolean().default(true),
      }).default({}),

      // CloudWatch settings
      cloudWatch: cloudWatchTopObject.default({}),

      // Console settings
      console: consoleObject.default({}),

      // File settings
      file: fileObject.default({}),
      errorFile: fileObject.default({}),

      // Categories
      categories: Joi.object()
        .pattern(
          Joi.string(),
          Joi.object({
            tags: Joi.object().pattern(
              Joi.string(),
              Joi.alternatives(
                onOffDefaultLevelEnum,
                Joi.object({
                  allowLevel: offDefaultLevelEnum.description(`\
Enable the tag for log entries with severity levels equal to or greater than the provided value`),
                  level: defaultLevelEnum.description('Alters the level of the log entry'),
                  other: onOffDefaultLevelEnum.description('Which value to use for transports not listed'),
                  file: onOffDefaultLevelEnum,
                  console: onOffDefaultLevelEnum,
                  errorFile: onOffDefaultLevelEnum,
                  cloudWatch: onOffDefaultLevelEnum,
                }),
              ),
            ),
            file: Joi.alternatives(fileObject, onOffDefaultLevelEnum),
            errorFile: Joi.alternatives(fileObject, onOffDefaultLevelEnum),
            console: Joi.alternatives(consoleObject, onOffDefaultLevelEnum),
            cloudWatch: Joi.alternatives(cloudWatchObject, onOffDefaultLevelEnum),
          }),
        )
        .default({}),

      // Testing
      unitTest: Joi.boolean(),
    }).label('Loggers options');
    // Joi model for options (end)
    // ===========================

    // TODO cache options schema
    let validation = optionsSchema.validate(options);
    if (validation.error) throw new Error(validation.error.message);
    // Add defaults to default empty objects
    validation = optionsSchema.validate(validation.value);
    if (validation.error) throw new Error(validation.error.message);
    return validation.value;
  }

  /**
   * Starts the logger after the constructor or stop() is called
   */
  start() {
    if (!this.props.stopped) return;

    // This is a synchronous method so reentrancy is impossible unless there's an infinite loop
    if (this.props.starting) throw new Error('Starting');

    this.props.starting = true;

    const { options } = this;

    if (options.unitTest) {
      // eslint-disable-next-line no-console
      console.log(`Unit test mode enabled  [info ${myName}]`);

      this.unitTest = {
        entries: [],
        groupIds: {},
        dataCount: 0,
        throwErrorFileError: true,
      };

      transportNames.forEach((transport) => {
        this.unitTest[transport] = { entries: [] };
      });
    }

    this.props.stopped = false;

    // ==========================================================================
    // Create one logger for uncaught exceptions and unhandled Promise rejections

    // process.on('uncaughtException') is dangerous and doesn't work for exceptions thrown in a function called by the
    // event loop -- e.g., setTimeout(() => {throw...})
    // Use the magic in Winston transports instead to catch uncaught exceptions
    const unhandledLoggers = this.logger(reservedCategories.unhandled);
    if (unhandledLoggers.isLevelEnabled('error')) {
      // Create a real Winston logger that has a transport with handleExceptions: true
      unhandledLoggers.winstonLogger();
      // Store this function so it can be removed later
      this.props.unhandledPromiseListener = (error) => {
        unhandledLoggers.error('Unhandled Promise rejection', { error });
      };
      process.on('unhandledRejection', this.props.unhandledPromiseListener);
    }

    this.props.starting = false;

    if (!this.props.restarting && options.say.ready) {
      const { service = '', stage = '', version = '', commitSha = '' } = options;

      this.log(
        undefined,
        `Ready: ${service} ${stage} v${version} ${commitSha} [${myName} v${myVersion}]`,
        undefined,
        undefined,
        reservedCategories.log,
      );
    }
  }

  /**
   * Creates a directory for log files
   * @param {string[]} directories
   * @returns {string} A directory path
   * @ignore
   */
  static createLogDirectory({ directories }) {
    if (!directories) return undefined;

    let logDirectory;

    directories.every((dir) => {
      try {
        fs.mkdirSync(dir, { recursive: true });
        fs.accessSync(dir, fs.constants.W_OK);
      } catch (error) {
        return true; // Next directory
      }
      logDirectory = dir;
      return false; // Stop iterating
    });

    if (!logDirectory) {
      // eslint-disable-next-line no-console
      console.error(`Failed creating log file directory. Directories attempted:  [error ${myName}]
${directories.join(`  [error ${myName}]\n`)}  [error ${myName}]`);
    }

    return logDirectory;
  }

  /**
   * Checks a category value
   * @param {string} [category]
   * @param {string} [backupCategory]
   * @returns {string} Returns the provided category if it is a truthy string; otherwise, returns the default category.
   * Logs a warning when the value is truthy and its type is not a string.
   * @throws When this.options.unitTest is true, throws an exception if the category is not a string
   */
  category(category, backupCategory) {
    if (category) {
      let type = typeof category;
      if (type === 'string') return category;
      if (type === 'object' && category instanceof Array) type = 'Array';

      // =================================================
      // Value with invalid datatype provided for category
      const error = Error(`Invalid datatype provided for category (${type})`);

      const stack = error.stack.replace(stripStack, '');
      this.log('error', stack, undefined, undefined, reservedCategories.log);
      // eslint-disable-next-line no-console
      console.error(`${stack}  [error ${myName}]`);

      // Throw exception when unit testing
      if (this.options.unitTest) throw error;
    }

    return backupCategory || this.options.defaultCategory;
  }

  /**
   * Processes tag switches for one category specified in this.options
   * @param {string} category
   * @returns {boolean} true only if tag switches are defined for the category
   * @ignore
   */
  processCategoryTags(category) {
    if (category in this.props.hasCategoryTags) return this.props.hasCategoryTags[category];

    // this.options looks like:
    // categories: {
    //   foo: {
    //     tags: {
    //       sql: {
    //         file: 'on'
    let tags = this.options.categories[category];
    if (tags) ({ tags } = tags);

    if (!tags) {
      this.props.hasCategoryTags[category] = false;
      return false;
    }

    const categoryTags = {};
    this.props.categoryTags[category] = categoryTags;

    // This code is only called once per category so use of Object.entries is fine
    Object.entries(tags).forEach(([tag, tagInfo]) => {
      if (typeof tagInfo === 'string') {
        // Level name
        categoryTags[tag] = { on: tagInfo };
      } else {
        categoryTags[tag] = tagInfo;
      }
    });

    this.props.hasCategoryTags[category] = true;
    return true;
  }

  /**
   * Determines whether a log entry can be sent to a transport
   * @param {string} transportName
   * @param {object} info Log entry
   * @returns {object} Either returns logEntry unaltered or a falsy value
   * @ignore
   */
  checkTags(transportName, info) {
    if (info.transports && !info.transports.includes(transportName)) return false;
    if (this.unitTest) this.unitTest[transportName].entries.push(info);
    return info;
  }

  /**
   * Returns default meta for log entries
   * @param {string} category
   * @returns {object}
   * @ignore
   */
  static defaultMeta(category) {
    // Do not add more fields here. category is needed by the custom formatter for logging uncaught exceptions.
    return {
      category, // The category of the Winston logger, not the category provided to log() etc.
    };
  }

  /**
   * Combines a custom Winston formatter with format.ms()
   * @returns {object} A Winston formatter
   * @ignore
   */
  formatter() {
    return format.combine(winston.format((info) => this.format(info))(), format.ms());
  }

  /**
   * Winston customer formatter
   *  1. Enforces log() is called to support uncaught exception logging
   *  2. Manages this.unitTest object for unit test validation
   *  3. Adds 'ms' to log entries
   * @param {object} info The log entry to format
   * @returns {object} info or false
   * @ignore
   */
  format(info) {
    if (info instanceof LogEntry) {
      if (this.unitTest) {
        this.unitTest.entries.push(info);
        if (info.groupId) this.unitTest.groupIds[info.groupId] = null;
        if (info.data) ++this.unitTest.dataCount;
      }
      return info;
    }
    // ========================================================
    // This is the uncaught exception handler. Reroute to log()
    const { category } = info; // From defaultMeta
    delete info.category;
    let { level } = info;
    if (!level || typeof level !== 'string') {
      level = 'error';
    } else {
      delete info.level;
    }
    if (info.stack) {
      // Remove stack from logEntry.message
      const { message } = info;
      let index = message.indexOf('\n');
      if (index >= 0) {
        const index2 = message.indexOf('\r');
        if (index2 >= 0 && index2 < index) index = index2;
        info.message = message.substr(0, index);
      }
    }
    this.log(level, info, undefined, category);
    return false;
  }

  /**
   * Console formatter for 'no data'
   * @param {object} info A log entry
   * @returns {string}
   * @ignore
   */
  static printf(info) {
    const { id, level, ms, message, category, tags } = info;
    let colorBegin;
    let colorEnd;
    {
      // Extract color codes from the level
      const codes = level.match(ansiRegexEngine);
      if (codes) {
        [colorBegin, colorEnd] = codes;
      } else {
        // eslint-disable-next-line no-multi-assign
        colorBegin = colorEnd = '';
      }
    }
    const spaces = message ? '  ' : '';
    const t2 = tags.slice(1);
    t2.unshift(category);
    return `${colorBegin}${ms} ${colorEnd}${message}${colorBegin}${spaces}[${tags[0]} ${t2.join(
      ' ',
    )} ${id}]${colorEnd}`;
  }

  /**
   * Creates a console transport
   * @param {string} level
   * @param {boolean} handleExceptions
   * @param {object} settings
   * @returns {object} A new console transport
   * @ignore
   */
  createConsoleTransport(level, handleExceptions, settings) {
    if (!settings) settings = this.options.console;
    const { colors, data, childErrors } = settings;

    if (data) {
      // Fancy console
      const consoleFormat = WinstonConsoleFormat({
        showMeta: true,
        inspectOptions: {
          depth: Infinity,
          colors,
          maxArrayLength: Infinity,
          breakLength: 120,
          compact: Infinity,
        },
      });

      const checkTags = winston.format((info) => this.checkTags('console', info))();

      return new winston.transports.Console({
        handleExceptions,
        level,
        format: colors
          ? format.combine(checkTags, format.colorize({ all: true }), consoleFormat)
          : format.combine(checkTags, consoleFormat),
      });
    }

    // Plain console
    const checkTags = winston.format((info) => {
      if (!childErrors && info.depth > 1) return false;
      return this.checkTags('console', info);
    })();

    const printf = format.printf(Loggers.printf);

    return new winston.transports.Console({
      handleExceptions,
      level,
      format: colors
        ? format.combine(checkTags, format.colorize({ all: true }), printf)
        : format.combine(checkTags, printf),
    });
  }

  /**
   * Sets this.props.cloudWatch*
   * @ignore
   */
  initCloudWatch() {
    if (this.props.cloudWatchTransports) return;
    this.props.cloudWatchTransports = [];

    if (!this.props.cloudWatchStream) {
      let stream = this.props.created.replace('T', ' ');
      // CloudWatch UI already sorts on time
      stream = `${stream} ${this.props.hostId}`;
      stream = stream.replace(/:/g, '');
      this.props.cloudWatchStream = stream;
    }
  }

  /**
   * Creates Winston logger for CloudWatch errors that logs to the console and possibly to a file
   * @returns {object} logger
   * @ignore
   */
  createCloudWatchErrorLoggers() {
    const transports = [];

    // Console
    transports.push(this.createConsoleTransport('error', false));

    const { options } = this;
    const { cloudWatch: category } = reservedCategories;

    // File
    const settings = options.categories[category] || {};
    const fileOptions = { ...this.options.errorFile };

    let level = settings.errorFile;
    if (level instanceof Object) {
      Object.assign(fileOptions, level);
      level = undefined;
    }

    if (!level) ({ level } = fileOptions);
    if (!level) level = 'off';
    else if (level === 'default') {
      level = options.defaultLevel;
    } else if (level === 'on') {
      level = 'error';
    }

    if (level !== 'off') {
      const logDirectory = Loggers.createLogDirectory(fileOptions);

      if (logDirectory) {
        const filename = path.join(logDirectory, `${category}-%DATE%`);

        const { maxSize, maxFiles, utc, zippedArchive, datePattern } = fileOptions;

        try {
          transports.push(
            new winston.transports.DailyRotateFile({
              filename,
              extension: '.log',
              datePattern,
              utc,
              zippedArchive,
              maxSize,
              maxFiles,
              format: format.json(),
              level,
              handleExceptions: false,
            }),
          );
        } catch (error) {
          // eslint-disable-next-line no-console
          console.error(`Failed creating CloudWatch error file transport: ${filename}  [error ${myName}]
${error}  [error ${myName}]`);
        }
      }
    }

    return winston.createLogger({
      defaultMeta: Loggers.defaultMeta(reservedCategories.cloudWatch),
      exitOnError: false,
      format: this.formatter(),
      levels: this.props.levelSeverity,
      transports,
    });
  }

  /**
   * Handles errors from the CloudWatch transport
   * @param {object} error
   * @ignore
   */
  onCloudWatchError(error) {
    // eslint-disable-next-line no-underscore-dangle
    if (ignoreCloudWatchErrors.includes(error.__type)) return;
    this.logger(reservedCategories.cloudWatch).error(error);
  }

  /**
   * Flushes a CloudWatch transport. See https://github.com/lazywithclass/winston-cloudwatch/issues/128.
   * @param {object} transport
   * @param {Number} timeout
   * @returns {Promise}
   * @ignore
   */
  flushCloudWatchTransport(transport, timeout) {
    // TODO Fix this when WinstonCloudWatch makes flush timeout an option
    // https://github.com/lazywithclass/winston-cloudwatch/issues/129
    // This ends up taking way too long if, say, the aws-sdk is not properly configured. Submit issue to
    // winston-cloudwatch.

    // timeout = 1000; // For testing

    transport.flushTimeout = Date.now() + timeout;
    return new Promise((resolve) => {
      transport.kthxbye((error) => {
        // error = new Error('testing this'); // For testing
        if (error) this.onCloudWatchError(error);
        resolve();
      });
    });
  }

  /**
   * Flushes Cloudwatch transports
   * @returns {Promise}
   */
  async flushCloudWatchTransports() {
    const { cloudWatchTransports } = this.props;
    if (!cloudWatchTransports || !cloudWatchTransports.length) return;

    // Flush timeout is only on the top-level cloudWatch key
    const { flushTimeout } = this.options.cloudWatch;

    let flushMessageTask;
    let flushMessageSent;

    if (this.options.say.flushing) {
      // Output a message if flush takes longer than 2.5 seconds
      flushMessageTask = setTimeout(() => {
        const duration = humanizeDuration(flushTimeout);
        flushMessageSent = true;
        flushMessageTask = undefined;
        // eslint-disable-next-line no-console
        console.log(`Waiting up to ${duration} to flush AWS CloudWatch Logs  [info ${myName}]`);
      }, 2500);
    }

    await Promise.all(cloudWatchTransports.map((transport) => this.flushCloudWatchTransport(transport, flushTimeout)));

    // For testing the message
    // await new Promise( (resolve) => setTimeout(resolve, 10000));

    if (flushMessageTask) clearTimeout(flushMessageTask);

    if (flushMessageSent) {
      // eslint-disable-next-line no-console
      console.log(`Flushed AWS CloudWatch Logs  [info ${myName}]`);
    }
  }

  /**
   * Flushes transports that support flushing, which is currently only CloudWatch.
   * @returns {Promise}
   */
  flush() {
    return this.restart();
  }

  /**
   * Closes all loggers
   * @returns {Promise}
   * @throws {None}
   * @ignore
   */
  async close() {
    await this.flushCloudWatchTransports();

    // Close loggers in the background except the CloudWatch error logger
    await Promise.all(
      Object.entries(this.props.winstonLoggers).map(([category, logger]) => {
        if (!logger.writable || category === reservedCategories.cloudWatch) return Promise.resolve();
        return new Promise((resolve, reject) => {
          logger
            .once('error', reject)
            .once('close', resolve)
            .once('finish', () => setImmediate(() => logger.close()))
            .end();
        }).catch((error) =>
          // eslint-disable-next-line no-console
          console.error(`Failed closing '${category}'  [error ${myName}]
${error}  [error ${myName}]`),
        );
      }),
    );

    // Close the CloudWatch error logger last
    if (this.props.cloudWatchTransports) {
      // Flush again because uncaught exceptions can be sent to CloudWatch transports during close
      // https://github.com/lazywithclass/winston-cloudwatch/issues/129
      await this.flushCloudWatchTransports();
      delete this.props.cloudWatchTransports;

      if (this.unitTest) {
        const count = this.unitTest.entries.length;
        this.onCloudWatchError(new Error('Expected error: Testing CloudWatch error while stopping'));
        if (count === this.unitTest.entries.length) throw new Error('CloudWatch error handler failed');
      }
    }

    this.props.winstonLoggers = {};

    const errorLogger = this.props.winstonLoggers[reservedCategories.cloudWatch];

    if (errorLogger && errorLogger.writable) errorLogger.close();

    if (this.unitTest) {
      // Test error handlers after closing loggers
      if (this.unitTest.flush) process.exit();
      await new Promise((resolve) => setTimeout(resolve, 1));
    }

    if (this.props.unhandledPromiseListener) {
      process.removeListener('unhandledRejection', this.props.unhandledPromiseListener);
      delete this.props.unhandledPromiseListener;
    }

    this.props.stopping = false;
    this.props.stopped = true;

    if (!this.props.restarting && this.options.say.stopped) {
      const { service = '', stage = '', version = '', commitSha = '' } = this.options;
      // eslint-disable-next-line no-console
      console.log(`Stopped: ${service} ${stage} v${version} ${commitSha}  [info ${myName} v${myVersion}]`);
    }
  }

  /**
   * Restarts
   * @returns {Promise}
   */
  async restart() {
    const { props } = this;
    if (props.starting) throw new Error('Starting'); // Impossible

    if (props.stopped) {
      this.start();
    } else {
      ++props.restarting;
      try {
        await this.stop();
        this.start();
      } finally {
        --props.restarting;
      }
    }
  }

  /**
   * Flushes loggers and stops them
   * @returns {Promise}
   * @throws {None}
   */
  async stop() {
    if (this.props.stopped) return;

    if (this.props.stopping) {
      // Stop is already running. Wait for it to finish.
      await new Promise((resolve) => {
        if (this.props.stopWaiters) {
          this.props.stopWaiters.push(resolve);
        } else {
          this.props.stopWaiters = [resolve];
          if (this.unitTest) this.unitTest.hasStopWaiters = true;
        }
      });

      return;
    }

    if (!this.props.restarting && this.options.say.stopping) {
      const { service, stage, version } = this.options;
      this.log(undefined, `Stopping: ${service} v${version} ${stage}`, undefined, undefined, reservedCategories.log);
    }

    this.props.stopping = true;

    if (this.unitTest) {
      this.stop().then(() => {
        if (!this.unitTest.hasStopWaiters) throw new Error('Waiting while stopping failed');
      });
    }

    await this.close();

    if (this.props.stopWaiters) {
      this.props.stopWaiters.forEach((resolve) => resolve());
      delete this.props.stopWaiters;
    }
  }

  /**
   * Creates a Winston logger for a category
   * @param {string} category
   * @param {object} defaults
   * @returns {object} Winston logger
   * @ignore
   */
  createWinstonLoggers(category) {
    if (this.props.stopped) throw new Error('Stopped');

    let logger;

    if (category === reservedCategories.cloudWatch) {
      // ======================================================================
      // Write winston-cloudwatch errors to the console and, optionally, a file
      if (!WinstonCloudWatch) throw new Error('winston-cloudwatch is not installed'); // This can't happen
      logger = this.createCloudWatchErrorLoggers();
    } else {
      if (this.props.stopping) throw new Error('Stopping');

      const { options } = this;
      const settings = options.categories[category] || {};

      const transports = [];

      // ====
      // File
      {
        const fileOptions = { ...options.file };

        let level = settings.file;
        if (level instanceof Object) {
          Object.assign(fileOptions, level);
          level = undefined;
        }
        if (!level) ({ level } = fileOptions);
        if (!level) level = 'off';
        else if (level === 'default') {
          level = options.defaultLevel;
        } else if (level === 'on') {
          level = 'info';
        }

        if (level !== 'off') {
          const logDirectory = Loggers.createLogDirectory(fileOptions);

          if (logDirectory) {
            const filename = path.join(logDirectory, `${category}-%DATE%`);

            const checkTags = winston.format((info) => this.checkTags('file', info))();
            const { maxSize, maxFiles, utc, zippedArchive, datePattern } = fileOptions;

            try {
              transports.push(
                new winston.transports.DailyRotateFile({
                  filename,
                  extension: '.log',
                  datePattern,
                  utc,
                  zippedArchive,
                  maxSize,
                  maxFiles,
                  format: format.combine(checkTags, format.json()),
                  level,
                  handleExceptions: category === reservedCategories.unhandled,
                }),
              );
            } catch (error) {
              // eslint-disable-next-line no-console
              console.error(`Failed creating file transport: ${filename}  [error ${myName}]
${error}  [error ${myName}]`);
            }
          }
        }
      }

      // ==========
      // Error file
      {
        const fileOptions = { ...options.errorFile };
        let level = settings.errorFile;
        if (level instanceof Object) {
          Object.assign(fileOptions, level);
          level = undefined;
        }
        if (!level) ({ level } = fileOptions);
        if (!level) level = 'off';
        else if (level === 'default') {
          level = options.defaultLevel;
        } else if (level === 'on') {
          level = 'error';
        }

        if (level !== 'off') {
          const logDirectory = Loggers.createLogDirectory(fileOptions);

          if (logDirectory) {
            const filename = path.join(logDirectory, `${category}-error-%DATE%`);

            const checkTags = winston.format((info) => this.checkTags('errorFile', info))();
            const { maxSize, maxFiles, utc, zippedArchive, datePattern } = fileOptions;

            try {
              transports.push(
                new winston.transports.DailyRotateFile({
                  filename,
                  extension: '.log',
                  datePattern,
                  zippedArchive,
                  utc,
                  maxSize,
                  maxFiles,
                  format: format.combine(checkTags, format.json()),
                  level,
                  handleExceptions: category === reservedCategories.unhandled,
                }),
              );
            } catch (error) {
              // eslint-disable-next-line no-console
              console.error(`Failed creating error file transport: ${filename}  [error ${myName}]
${error}  [error ${myName}]`);
              // Ignore the error - unable to write to the directory
            }
          }
        }
      }

      // ============================
      // CloudWatch
      if (!noCloudWatch) {
        let awsOptions = { ...options.cloudWatch };
        let level = settings.cloudWatch;
        if (level instanceof Object) {
          Object.assign(awsOptions, level);
          level = undefined;
        }
        if (!level) ({ level } = awsOptions);
        if (!level) level = 'off';
        else if (level === 'default') {
          level = options.defaultLevel;
        } else if (level === 'on') {
          level = 'warn';
        }

        if (level !== 'off') {
          let { logGroup: logGroupName } = awsOptions;

          if (!awsOptions.region) {
            const { env } = process;
            awsOptions.region = env.AWS_CLOUDWATCH_LOGS_REGION || env.AWS_REGION;
          }

          if (!awsOptions.region) {
            // eslint-disable-next-line no-console
            console.error(`Region was not specified for AWS CloudWatch Logs for '${category}'  [error ${myName}]`);
          } else if (!logGroupName) {
            // eslint-disable-next-line no-console
            console.error(`Log group was not specified for AWS CloudWatch Logs for '${category}'  [error ${myName}]`);
          } else {
            if (!WinstonCloudWatch) {
              try {
                // Lazy load winston-cloudwatch
                // eslint-disable-next-line global-require
                WinstonCloudWatch = require('winston-cloudwatch');
              } catch (error) {
                // eslint-disable-next-line no-console
                console.warn(`winston-cloudwatch is not installed: ${error.message}  [warn ${myName}]`);
                noCloudWatch = true;
              }
            }

            if (WinstonCloudWatch) {
              this.initCloudWatch();
              const { uploadRate } = awsOptions;

              // Remove invalid characters from log group name
              // See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogGroup.html
              logGroupName = logGroupName.replace(/[^a-z0-9_/.#-]/gi, '');

              // log group ends with a slash
              logGroupName = `${logGroupName.replace(/[/]+$/, '').replace(/[/][/]+$/g, '')}/`;

              awsOptions = { region: awsOptions.region };

              const checkTags = (info) => {
                // TODO Submit feature request. See cwTransportShortCircuit
                if (!this.checkTags('cloudWatch', info)) return '';
                return JSON.stringify(info);
              };

              // TODO add more options supported by winston-cloudwatch
              // See https://github.com/lazywithclass/winston-cloudwatch/blob/e705a18220bc9be0564ad27b299127c6ee56a28b/typescript/winston-cloudwatch.d.ts

              if (this.options.say.cloudWatch && !(logGroupName in this.props.cloudWatchLogGroups)) {
                this.props.cloudWatchLogGroups[logGroupName] = null;
                // eslint-disable-next-line no-console
                console.log(
                  // eslint-disable-next-line max-len
                  `Writing to AWS CloudWatch Logs stream: ${logGroupName}${this.props.cloudWatchStream}  [info ${myName}]`,
                );
              }

              const transport = new WinstonCloudWatch({
                messageFormatter: checkTags,
                logStreamName: this.props.cloudWatchStream,
                ensureLogGroup: true,
                logGroupName,
                awsOptions,
                level,
                errorHandler: (error) => this.onCloudWatchError(error),
                uploadRate,
                handleExceptions: category === reservedCategories.unhandled,
              });

              this.props.cloudWatchTransports.push(transport);
              transports.push(transport);
            }
          }
        }
      }

      // ===============================================
      // Console
      // Must be last because it's the default transport
      {
        const consoleOptions = { ...options.console };
        let level = settings.console;
        if (level instanceof Object) {
          Object.assign(consoleOptions, level);
          level = undefined;
        }
        if (!level) ({ level } = consoleOptions);
        if (!level) level = 'info';
        else if (level === 'default') {
          level = options.defaultLevel;
        } else if (level === 'on') {
          level = 'info';
        }

        // Winston wants at least one transport (error file transport is intentionally ignored because it's only error)
        // so console is always active. This has the added benefit of ensuring that the unhandled exception logger has
        // at least one transport with handleExceptions: true; otherwise, undhandled exceptions will kill the process.
        if (!transports.length && level === 'off') level = 'error';

        if (level !== 'off') {
          transports.push(
            this.createConsoleTransport(level, category === reservedCategories.unhandled, consoleOptions),
          );
        }
      }

      // All transports created
      logger = winston.createLogger({
        defaultMeta: Loggers.defaultMeta(category),
        exitOnError: false,
        format: this.formatter(),
        levels: this.winstonLevels,
        transports,
      });
    }

    this.props.winstonLoggers[category] = logger;
    return logger;
  }

  /**
   * Returns a Winston logger associated with a category
   * @param {string} [category]
   * @returns {object} A Winston logger
   */
  winstonLogger(category) {
    category = this.category(category);
    let logger = this.props.winstonLoggers[category];
    if (!logger) logger = this.createWinstonLoggers(category);

    return logger;
  }

  /**
   * Accessor for the options provided for a category
   * @param {string} [category]
   * @returns {object} An object or undefined
   * @ignore
   */
  categoryOptions(category) {
    return this.options.categories[this.category(category)];
  }

  /**
   * Returns a logger associated with a category
   * @param {string} [category]
   * @returns {Loggers|object}
   */
  logger(category) {
    category = this.category(category);
    const { loggers } = this.props;

    let logger = loggers[category];
    if (logger) return logger;

    if (category === this.options.defaultCategory) {
      logger = this;
    } else {
      // eslint-disable-next-line no-use-before-define
      logger = new Logger(this, undefined, undefined, category);
    }

    loggers[category] = logger;
    return logger;
  }

  /**
   * Associates a logger with a category
   * @param {string} [category]
   * @param {Loggers|object} [logger]. If falsy, the category will not be associated with a logger.
   * @returns {Logger|undefined} The stored logger
   */
  setLogger(category, logger) {
    category = this.category(category);
    const { loggers } = this.props;

    if (!logger) {
      delete loggers[category];
      return undefined;
    }

    // Ensure the logger's category is the same as the category argument
    if (category !== logger.category()) {
      // eslint-disable-next-line no-use-before-define
      logger = new Logger(logger, undefined, undefined, category);
    }
    loggers[category] = logger;
    return logger;
  }

  /**
   * Creates a child logger
   * @returns {object}
   */
  child(...args) {
    // eslint-disable-next-line no-use-before-define
    return new Logger(this, ...args);
  }

  /**
   * Retrieves a named Stack instance
   * @param {string} [name]
   * @returns {Stack}
   */
  stack(name) {
    let { stacks } = this.props;

    // eslint-disable-next-line no-multi-assign
    if (!stacks) stacks = this.props.stacks = {};

    let stack = stacks[name];

    // eslint-disable-next-line no-multi-assign
    if (!stack) stack = stacks[name] = new Stack();

    return stack;
  }

  /**
   * @type {Loggers}
   */
  get loggers() {
    return this;
  }

  /**
   * @type {Loggers}
   */
  get parent() {
    return this;
  }

  /**
   * Indicates whether this object and its child loggers are ready to log messages. false when messages can not be
   * logged because the logger is stopping or has been stopped.
   * @type {boolean}
   */
  get ready() {
    const { props } = this;
    return !props.stopped && !props.stopping;
  }

  /**
   * Transforms arguments sent to log methods, child(), and isLoggerEnabled()
   * @param {*} [tags]
   * @param {*} [message]
   * @param {*} [data]
   * @param {*} [context]
   * @param {string} [category]
   * @returns {LogArgs}
   * @ignore
   */
  transformArgs(tags, message, data, context, category) {
    if (tags instanceof LogArgs) return tags;

    let extra;

    if (tags instanceof Error) {
      // =====================================
      // The first argument is an Error object
      category = context;
      context = data;
      data = message;
      message = tags;
      tags = undefined;
    } else if (
      tags instanceof Object &&
      !(tags instanceof Array) &&
      context === undefined &&
      message === undefined &&
      data === undefined &&
      ('tags' in tags || 'category' in tags || 'context' in tags || 'message' in tags || 'data' in tags)
    ) {
      category = tags.category || category;
      // eslint-disable-next-line no-restricted-syntax
      for (const key in tags)
        if (!(key in transformArgsProperties)) {
          if (!extra) extra = {};
          extra[key] = tags[key];
        }
      ({ tags, message, data, context } = tags);
    } else if (
      context instanceof Object &&
      message === undefined &&
      data === undefined &&
      ('tags' in context || 'category' in context || 'context' in context)
    ) {
      // This happens when child() is called with the second argument being an object
      category = context.category || category;
      // eslint-disable-next-line no-restricted-syntax
      for (const key in context)
        if (!(key in transformArgsProperties)) {
          if (!extra) extra = {};
          extra[key] = context[key];
        }
      if (context.tags) tags = this.tags(tags, context.tags);
      ({ context } = context);
    } else if (
      message instanceof Object &&
      !(message instanceof Array) &&
      context === undefined &&
      data === undefined &&
      ('tags' in message || 'category' in message || 'context' in message || 'message' in message || 'data' in message)
    ) {
      category = message.category || category;
      // eslint-disable-next-line no-restricted-syntax
      for (const key in message) {
        if (!(key in transformArgsProperties)) {
          if (!extra) extra = {};
          extra[key] = message[key];
        }
      }
      if (message.tags) tags = this.tags(tags, message.tags);
      ({ message, data, context } = message);
    }

    if (context !== undefined) context = [context];

    if (message === undefined && data instanceof Error) {
      message = data;
      data = undefined;
    } else {
      if (message instanceof Object && !(message instanceof Array) && !Loggers.hasProperty(message)) {
        message = undefined;
      }

      if (data instanceof Object && !(data instanceof Array) && !Loggers.hasProperty(data)) data = undefined;

      // Swap message and data
      if (message instanceof Object && data !== null && data !== undefined && !(data instanceof Object)) {
        const data2 = data;
        data = message;
        message = data2;
      }
    }

    const ret = {
      tags: this.tags(tags),
      context,
      message,
      data,
      extra,
      category,
    };

    return Object.assign(new LogArgs(), ret);
  }

  /**
   * @escription Determines whether a log entry will be sent to a logger
   * @param {*} [tagsOrNamedParameters]
   * @param {string} [category]
   * @returns {object} If the message will be logged, returns an object with properties tags, logger, level, transports,
   * and category. Otherwise, returns false.
   */
  isLevelEnabled(tagsOrNamedParameters, category) {
    if (this.props.stopped) {
      const stack = new Error().stack.replace(stripStack, '');
      // eslint-disable-next-line no-console
      console.error(`Stopped  [error ${myName}]
${stack}  [error ${myName}]`);
      return false;
    }

    let tags;
    ({ tags, category } = this.transformArgs(tagsOrNamedParameters, undefined, undefined, undefined, category));

    // transformArgs can not return the default category because Logger calls it
    category = this.category(category); // Use default category if not provided

    // Mix in category logger's tags
    // Because child loggers can be assigned via this.setLogger(category) after child loggers are created
    // (#1 - search for Mix in)
    {
      const cat = this.logger(category);
      if (cat !== this) tags = cat.tags(tags);
    }

    this.processCategoryTags(category);

    // ==========================================================
    // Determine the level to use when determining whether to log
    let level;
    let tagNames;

    {
      // =============================================================
      // logLevel meta tag is used when methods like info() are called
      const value = tags.logLevel;
      if (value) {
        tags = { ...tags };
        delete tags.logLevel;
        level = value === 'default' ? this.options.defaultLevel : value;
        tags[level] = true;
      }

      // tags is returned by Loggers.tags() which doesn't involve parent classes so Object.keys() is acceptable
      tagNames = Object.keys(tags);

      if (!level) {
        // ====================================================================================
        // Populate level such that, for example, 'error' overrides 'debug' if both are present
        let levelNum = 100000;

        tagNames.forEach((tag) => {
          if (tags[tag]) {
            const num = this.props.levelSeverity[tag];
            if (num !== undefined && num < levelNum) {
              levelNum = num;
              level = tag === 'default' ? this.options.defaultLevel : tag;
            }
          }
        });
      }
    }

    // ===================================================
    // Add error tag when Error is provided as the message
    if (tags[addErrorSymbol]) {
      delete tags[addErrorSymbol];
      if (!tags.error) {
        tags.error = true;
        tagNames.unshift('error'); // error appears first
        if (!level) level = 'error';
      }
    }

    if (!level) level = this.options.defaultLevel;

    let transports;

    if (tagNames.length) {
      // Look for a blocked tag
      // TODO Defaults should be specified at the category level or via the category named 'default'
      // TODO Cache results for tags for the category that aren't yet defined in config
      const categoryTags = this.props.categoryTags[category];
      const defaultTags = this.props.categoryTags.default;
      let nextLevel = level;

      if (
        !tagNames.every((tag) => {
          // Return true to continue
          if (this.props.levelSeverity[tag] !== undefined) return true; // It's a level tag

          let categoryTransports;
          if (categoryTags) categoryTransports = categoryTags[tag];

          let checkDefault = true;
          // Check allowLevel
          if (categoryTransports) {
            const { allowLevel } = categoryTransports;
            if (allowLevel) {
              if (this.props.levelSeverity[level] <= this.props.levelSeverity[allowLevel]) return true;
              checkDefault = false;
            }
          }

          const defaultTransports = defaultTags[tag];

          if (checkDefault) {
            if (defaultTransports) {
              const { allowLevel } = defaultTransports;
              if (allowLevel) {
                // TODO cache this
                if (this.props.levelSeverity[level] <= this.props.levelSeverity[allowLevel]) return true;
              } else if (
                this.props.levelSeverity[level] <= this.props.levelSeverity[this.options.defaultTagAllowLevel]
              ) {
                // Defaults to warn (severity 1)
                // TODO Cache this
                return true;
              }
            } else if (this.props.levelSeverity[level] <= this.props.levelSeverity[this.options.defaultTagAllowLevel]) {
              // Defaults to warn (severity 1)
              // TODO Cache this
              return true;
            }
          }

          // The .on key specifies whether the (category, tag) pair is enabled
          // and is computed only once
          if (categoryTransports) {
            const { on } = categoryTransports;
            if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) return false;
          } else {
            if (!defaultTransports) return true;
            const { on } = defaultTransports;
            if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) return false;
          }

          // Alter level?
          if (categoryTransports) {
            let { level: lvl } = categoryTransports;
            if (lvl) {
              if (lvl === 'default') lvl = this.options.defaultLevel;
              if (this.props.levelSeverity[lvl] < this.props.levelSeverity[nextLevel]) {
                // TODO Exit early if isLevelEnabled(lvl) is false
                nextLevel = lvl;
              }
            }
          }

          // Process per-transport switches. Remove keys from transports.
          transportNames.forEach((transport) => {
            if (transports && !(transport in transports)) return true;

            checkDefault = true;

            if (categoryTransports) {
              let on = categoryTransports[transport];
              if (on) {
                checkDefault = false;
                if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) {
                  if (!transports) transports = { ...transportObj };
                  delete transports[transport];
                }
              } else {
                on = categoryTransports.other;
                if (on) {
                  checkDefault = false;
                  if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) {
                    if (!transports) transports = { ...transportObj };
                    delete transports[transport];
                  }
                }
              }
            }

            if (checkDefault && defaultTransports) {
              let on = defaultTransports[transport];
              if (on) {
                if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) {
                  if (!transports) transports = { ...transportObj };
                  delete transports[transport];
                }
              } else {
                on = defaultTransports.other;
                if (on) {
                  if (this.props.levelSeverity[level] > this.props.levelSeverity[on]) {
                    if (!transports) transports = { ...transportObj };
                    delete transports[transport];
                  }
                }
              }
            }

            return true;
          });

          return !transports || Loggers.hasProperty(transports);
        })
      ) {
        return false;
      }

      // Turn transports from an object to an array of keys
      if (transports) transports = Object.keys(transports);

      // Change the level based on tag settings
      if (nextLevel) level = nextLevel;
    }

    const logger = this.winstonLogger(category);
    if (!logger.isLevelEnabled(level)) return false;

    return {
      category,
      level,
      logger,
      tags,
      transports,
    };
  }

  /**
   * Alias for isLevelEnabled
   * @param {Array} [args]
   */
  levelEnabled(...args) {
    return this.levelEnabled(...args);
  }

  /**
   * Converts an object to a string
   * @param {*} value null not allowed
   * @returns {string|boolean} A string or false
   * @ignore
   */
  objectToString(value) {
    if (value instanceof Array) {
      value = JSON.parse(prune(value, this.options.message.depth, this.options.message.arrayLength));
      return util.inspect(value);
    }

    // Allow the object to speak for itself
    let msg = value.toString();
    if (msg !== '[object Object]') return msg;

    msg = value.message;

    if (msg) {
      msg = msg.toString(); // Avoid recursion
      if (msg === '[object Object]') return false;
      return msg;
    }

    return false;
  }

  /**
   * Does nothing if the provided key is redacted. Helper function to combine 'message' and 'data'.
   * Handles overlapping keys in both. Sets state.currentData to state.data or state.dataData and then sets
   * state.currentData[key] to value.
   * @param {object} state An object with keys data, dataData, and currentData
   * @param {string} key
   * @param {*} value Value to store in the property named 'key'
   * @ignore
   */
  copyData(state, key, value) {
    // Check redaction (nonrecursive)
    if (value === undefined) return;
    if (typeof value === 'function') return;

    if (key in this.props.redact) return;

    if (!state.currentData) {
      state.data = {};
      state.currentData = state.data;
    } else if (state.currentData === state.data && key in state.data && value !== state.data[key]) {
      // message and data overlap; their values differ
      state.dataData = {};
      state.currentData = state.dataData;
    }

    state.currentData[key] = value;
  }

  /**
   * Creates a log entry
   * @param {object} info A value returned by isLevelEnabled()
   * @param {object} context
   * @param {*} message
   * @param {*} data
   * @param {object} [extra]
   * @param {Number} depth When falsy, create the 'root' log entry. When truthy, create a secondary entry that is in
   * the same group as the root log entry.
   * 1. When the level is in this.props.logStackLevels, the stack is added when falsy
   * 2. The logStack and noLogStack meta tags are applied when falsy
   * @returns {object} A log entry
   * @ignore
   */
  logEntry(info, context, message, data, extra, depth) {
    const entry = new LogEntry();
    const { level, tags } = info;

    // undefined values are placeholders for ordering and are deleted at the end of this method.
    // false values are not removed.
    Object.assign(entry, {
      message: undefined,
      level,
      timestamp: Loggers.now(),
      ms: false, // Set via a formatter; intentionally not removed
      tags: false,
      ...this.props.userMeta,
      category: info.category, // Overwritten by defaultMeta
      id: ulid(),
      groupId: false, // Set and removed by send()
      depth: 0, // Set and removed by send()
      stage: this.options.stage,
      hostId: this.props.hostId,
      service: this.options.service,
      version: this.options.version,
      commitSha: this.options.commitSha,
      logStack: false, // Set and removed by send()
      stack: false, // Set and removed by send()
      context: undefined,
      data: undefined,
      transports: info.transports,
    });

    // Points to data first and dataData if there are the same keys in message and data
    const state = {};

    // Mix in category logger's context (isLevelEnabled does this for tags)
    // Because child loggers can be assigned to to this.logger(category)
    // (#2 - search for Mix in)
    {
      const { category } = info;
      const logger = this.logger(category);
      if (logger !== this) context = logger.mergeContext(level, tags, category, context);
    }

    // ==========================================
    // Send message and/or data to event handlers
    if (message !== undefined && message != null) {
      const event = {
        category: entry.category,
        context,
        arg: message,
        level,
        tags,
        type: 'message',
        property: 'message',
      };
      try {
        this.emit('redact', event);
        ({ arg: message } = event);
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error('Redact message event handler failed', error);
      }

      if (message instanceof Object && !(message instanceof Array)) {
        // eslint-disable-next-line no-restricted-syntax, guard-for-in
        for (const key in message) {
          event.property = key;
          event.arg = message[key];
          try {
            this.emit('redact', event);
            message[key] = event.arg;
          } catch (error) {
            // eslint-disable-next-line no-console
            console.error('Redact context event handler failed', error);
          }
        }
      }
    }

    if (data !== undefined && data !== null) {
      const event = { category: entry.category, context, arg: data, level, tags, type: 'data', property: 'data' };
      try {
        this.emit('redact', event);
        ({ arg: data } = event);
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error('Redact data event handler failed', error);
      }

      if (data instanceof Object && !(data instanceof Array)) {
        // eslint-disable-next-line no-restricted-syntax, guard-for-in
        for (const key in data) {
          event.property = key;
          event.arg = data[key];
          try {
            this.emit('redact', event);
            data[key] = event.arg;
          } catch (error) {
            // eslint-disable-next-line no-console
            console.error('Redact context event handler failed', error);
          }
        }
      }
    }

    if (extra) {
      const event = { category: entry.category, context, level, tags };
      // eslint-disable-next-line no-restricted-syntax, guard-for-in
      for (const key in extra) {
        try {
          event.type = key;
          event.arg = extra[key];
          this.emit('redact', event);
          extra[key] = event.arg;
        } catch (error) {
          // eslint-disable-next-line no-console
          console.error('Redact data event handler failed', error);
        }
      }
    }

    // Combine message and data to state
    const items = [];

    if (message !== undefined) {
      const type = typeof message;
      if (type === 'object') {
        // includes null
        items.push(Loggers.toObject(message, 'message'));
      } else if (type !== 'function') {
        items.push(this.objectToString(message));
      }
    }

    if (data !== undefined && typeof data !== 'function') items.push(Loggers.toObject(data, 'data'));
    if (extra !== undefined) items.push(extra);

    items.forEach((item) => {
      if (item instanceof Object) {
        // eslint-disable-next-line no-restricted-syntax
        for (const key in item) {
          // message and stack are handled later
          if (!nonenumerableKeys.includes(key)) this.copyData(state, key, item[key]);
        }

        const { stack } = item;
        if (stack && typeof stack === 'string') this.copyData(state, 'stack', stack);

        // If the object has a conversion to string, use it. Otherwise, use its message property if it's a scalar.
        const str = this.objectToString(item);
        if (str) this.copyData(state, 'message', str);

        const msg = item.message;
        if (msg !== undefined && (!str || msg !== str)) {
          this.copyData(state, str ? '_message' : 'message', msg);
        }
      } else {
        // Copy message to data where it will be moved to meta
        this.copyData(state, 'message', item.toString());
      }
    });

    // ============================
    // Move keys in context to meta
    if (context) {
      let foundKey;

      this.props.metaProperties.forEach((key) => {
        let value = context[key];
        if (value === undefined || typeof value === 'function') return;

        if (value instanceof Date) value = value.toISOString();
        else if (value instanceof Object) return;

        if (!foundKey) {
          context = { ...context };
          foundKey = true;
        }

        // Rename object key to meta property
        entry[this.props.meta[key]] = value;
        delete context[key];
      });

      // Stop the data console transport from logging context like "context: Context {...}"
      if (!foundKey) context = { ...context };

      if (Loggers.hasProperty(context)) entry.context = context;
    }

    // =========================
    // Move keys in both to meta
    const { data: entryData } = state;
    if (entryData) {
      this.props.metaProperties.forEach((key) => {
        let value = entryData[key];
        if (value === undefined || typeof value === 'function') return;

        if (value instanceof Date) value = value.toISOString();
        else if (value instanceof Object) return;

        // Rename object key to meta property
        entry[this.props.meta[key]] = value;
        delete entryData[key];
      });

      entry.data = entryData;
    }

    // Remove meta properties that have undefined values
    // eslint-disable-next-line no-restricted-syntax, guard-for-in
    for (const key in entry) {
      const value = entry[key];
      if (value === undefined) delete entry[key];
    }

    // If entry.message doesn't exist or is undefined, '' is the message instead of the level
    if (entry.message === undefined) entry.message = '';

    if (state.dataData) entry.dataData = state.dataData;

    // Add stack trace?
    let addStack = !depth && info.level in this.props.logStackLevels;

    // Turn tags into an array and put the level in the front without modifying the object in entry.tags
    if (tags) {
      // Make sure meta tags are deleted

      let value = tags.noLogStack;

      if (value !== undefined) {
        if (!depth) addStack = !value;
        delete tags.noLogStack;
      }

      value = tags.logStack;
      if (value !== undefined) {
        if (!depth) addStack = value;
        delete tags.logStack;
      }

      // Move the level to the front of the tags array and sort the remaining tags
      const tags2 = Object.keys(tags).filter((tag) => tags[tag] && tag !== level);
      entry.tags = [level, ...tags2.sort()];
    } else {
      entry.tags = [level];
    }

    // Set the logStack meta
    if (addStack) entry.logStack = new Error().stack.replace(/^Error(\n|: )/, '').replace(stripStack, '');

    return entry;
  }

  /**
   * Sends log entries to a Winston logger
   * @param {object} info A value returned by isLevelEnabled()
   * @param {*} [context]
   * @param {*} [message]
   * @param {*} [data]
   * @param {object} [extra]
   * @param {Set<Error>} [errors] Errors already logged, to avoid recursion. Not using WeakSet because .size is needed
   * @param {Number} [depth] Recursion depth (defaults to 0)
   * @param {String} [groupId]
   * @ignore
   */
  send(info, context, message, data, extra, errors = new Set(), depth = 0, groupId = undefined) {
    // eslint wants groupId=
    const { category, tags, logger, level } = info;

    if (context instanceof Array) context = this.mergeContext(level, tags, category, ...context);

    const entry = this.logEntry(info, context, message, data, extra, depth);

    // ==========================================================================================================
    // Process the provided data. Call send() recursively when there are properties that contain Error instances.

    if (message instanceof Error) errors.add(message);
    else if (message === undefined && data instanceof Error) errors.add(data);

    /**
     * Objects added to dataMessages are sent to this method
     */
    const dataMessages = [];
    let dataCopied;

    const addData = depth < this.options.errors.depth;

    let { dataData } = entry;

    if (dataData) {
      delete entry.dataData;
      if (!addData) dataData = undefined;
    }

    let firstError;

    const { data: entryData } = entry;

    if (entryData) {
      if (addData) {
        // ======================================================================
        // Add Errors to errors array and remove the object reference from 'data'
        // eslint-disable-next-line guard-for-in, no-restricted-syntax
        for (const key in entryData) {
          const value = entryData[key];

          // eslint-disable-next-line no-continue
          if (!(value instanceof Error)) continue;

          // Check for circular references
          if (errors.size < this.options.errors.max && !errors.has(value)) {
            errors.add(value);
            dataMessages.push(value);
          }

          // Remove key from data - otherwise the error will reappear in the next call to send()
          if (data && key in data) {
            if (!dataCopied) {
              data = { ...data };
              dataCopied = true;
            }
            delete data[key];
          }

          if (!firstError || key === 'error') firstError = entryData[key];
          delete entryData[key];
        }
      }

      // =======================================================================
      // Prune data. This unfortunately removes keys that have undefined values.
      const newData = JSON.parse(prune(entryData, this.options.message.depth, this.options.message.arrayLength));

      {
        const { recursiveRedact } = this.props;
        if (recursiveRedact.length) deepCleaner(newData, recursiveRedact);
      }

      if (Loggers.hasProperty(newData)) entry.data = newData;
      else delete entry.data;
    }

    // ====================================================================
    // Remove falsy values from entry that were set to false by logEntry()
    if (!entry.logStack) delete entry.logStack;

    if (!entry.stack) {
      if (entry.logStack) {
        entry.stack = entry.logStack;
        delete entry.logStack;
      } else {
        delete entry.stack;
      }
    }

    const noMessage = !('message' in entry) && !('data' in entry) && !('context' in entry);

    ++depth;

    if (!noMessage || !firstError) {
      // ==========================
      // Set groupId and depth meta
      if (depth > 1) {
        entry.groupId = groupId || entry.id;
        entry.depth = depth;
      } else {
        delete entry.groupId;
        delete entry.depth;
      }

      // ==============
      // Send log event
      try {
        this.emit('log', entry);
      } catch (error) {
        // eslint-disable-next-line no-console
        console.error(`A log event listener failed: ${error}`);
      }

      // =========================================================
      // Only CloudWatch's error logger can be used while stopping
      if (this.props.stopping && category !== reservedCategories.cloudWatch) {
        const stack = new Error().stack.replace(stripStack, '');
        // eslint-disable-next-line no-console
        console.error(`Stopping  [error ${myName}]
${util.inspect(entry)}  [error ${myName}]
${stack}  [error ${myName}]`);
      } else {
        logger.log(level, entry);
      }
    }

    // Log child errors
    if (dataData) this.send(info, context, undefined, dataData, undefined, errors, depth, groupId || entry.id);

    dataMessages.forEach((dataMessage) =>
      this.send(info, context, dataMessage, data, undefined, errors, depth, groupId || entry.id),
    );
  }

  /**
   * Called by methods that are named after levels
   * @param {Loggers|logger} target
   * @param {string} level
   * @param {*} [message]
   * @param {*} [data]
   * @param {*} [context]
   * @param {string} [category]
   * @ignore
   */
  static logLevel(target, level, tags, message, data, context, category) {
    let logArgs;

    if (tags instanceof Array) {
      logArgs = target.transformArgs(tags, message, data, context, category);
    } else if (
      tags instanceof Object &&
      message === undefined &&
      data === undefined &&
      context === undefined &&
      ('tags' in tags || 'category' in tags || 'context' in tags || 'message' in tags || 'data' in tags)
    ) {
      logArgs = target.transformArgs(tags);
    } else {
      // tags is really message, and so on
      logArgs = target.transformArgs(undefined, tags, message, data, context);
    }

    logArgs.tags.logLevel = level;
    target.log(logArgs);
  }

  /**
   * Sends a log entry to transports.
   *
   * If tags is an Error object, ['error'] is used as the tags and the error is logged with message and data.
   * If tags is an object with tags, message, data, and/or category properties, those properties are used as follows:
   *
   *   1. tags = this.tags(tags.logLevel, tags.tags)
   *   2. message = tags.message
   *   3. data = tags.data
   *   4. context = tags.context
   *   5. category = tags.category
   * @param {*} [tags] See description
   * @param {*} [message]
   * @param {*} [data]
   * @param {*} [context]
   * @param {string} [category]
   */
  log(...args) {
    const args2 = this.transformArgs(...args);

    // transformArgs can not return the default category because Logger calls it
    args2.category = this.category(args2.category); // Use default category if not provided

    const { tags, context, message, data, extra, category } = args2;

    // Add 'error' tag if an error was provided in message or data
    if (
      !('error' in tags) && // can turn it off with false
      Loggers.hasError(message, data, extra)
    )
      tags[addErrorSymbol] = true;

    if (this.props.stopped) {
      // eslint-disable-next-line no-console
      console.error(`Stopped  [error ${myName}]
${util.inspect({
  category,
  tags,
  context,
  message,
  data,
  extra,
})}  [error ${myName}]
${new Error('Stopped').stack}  [error ${myName}]`);
    } else {
      const info = this.isLevelEnabled(args2);
      if (info) this.send(info, context, message, data, extra);
    }
  }
}

/**
 * Default meta properties. Values are either null or a string containing the meta property
 * name. For example, given the tuple a: 'b', property a is copied to meta.b.
 */
Loggers.defaultMetaProperties = { correlationId: undefined };

/**
 * Where log files are written
 */
Loggers.defaultFileDirectories = ['logs', '/tmp/logs', '.'];

/**
 *
 * These follow npm levels wich are defined at
 * https://github.com/winstonjs/winston#user-content-logging-levels with the addition of:
 *
 * - 'fail' : more severe than 'error'
 * - 'db' : more severe than 'http'
 * - 'more' : after 'info' ('more' noisy than 'info' but less noisy than 'verbose')
 *
 * Custom levels can be provided to the Loggers class's constructor; however, the Loggers class
 * assumes there is an 'error' level and the options model (via * the defaults) assumes the
 * following levels exist: error, warn, debug.
 *
 * See also https://github.com/winstonjs/winston#using-custom-logging-levels
 */
Loggers.defaultLevels = {
  levels: {
    fail: 1,
    error: 2,
    warn: 3,
    notice: 4,
    info: 5,
    more: 6,
    db: 7,
    http: 8,
    verbose: 9,
    debug: 10,
    silly: 11,
  },
  colors: {
    fail: 'red',
    more: 'cyan',
    db: 'yellow',
    notice: 'magenta',
  },
};

/**
 * This class manages a (tags, context, category) tuple. Many of its methods also accept tags and context
 * parameters, which, if provided, are combined with the object's corresponding properties. For example, if the object
 * is created with tags = ['apple'] log('banana') will use the tags 'apple' and 'banana.' This class has almost the same
 * interface as Loggers.
 */
class Logger {
  /**
   * Private Properties
   *  {object} props Has keys: tags, context, category, loggers, parent
   */

  /**
   * @constructor
   * @param {Loggers|object} parent
   * @param {*} [tags]
   * @param {*} [context]
   * @param {string} [category]
   * @ignore
   */
  constructor(parent, tags, context, category) {
    let loggers;

    if (parent instanceof Logger) {
      ({ loggers } = parent.props);
    } else {
      if (!(parent instanceof Loggers)) throw new Error('parent must be an instance of Loggers or Logger');
      loggers = parent;
    }

    let extra;
    let message;
    let data;

    ({ tags, message, data, extra, context, category } = parent.transformArgs(
      tags,
      undefined,
      undefined,
      context,
      category,
    ));

    // transformArgs can not return the default category because Logger calls it
    category = loggers.category(category); // Use default category if not provided

    if (data !== undefined) data = { data };
    if (message !== undefined && !(message instanceof Object)) message = { message };

    const args = [undefined, tags, category, message, data];

    if (context) args.push(...context);
    context = loggers.mergeContext(...args, extra);

    this.props = { loggers, parent, tags, context, category };

    // Dynamic logging-level methods
    this.props.loggers.addLevelMethods(this);
  }

  /**
   * @ignore
   */
  transformArgs(...args) {
    const { loggers } = this.props;
    const args2 = loggers.transformArgs(...args);

    // Mix in my tags and context
    args2.tags = this.tags(args2.tags);
    args2.category = this.category(args2.category);

    const { context: myContext } = this.props;

    // myContext is either an object or falsy
    if (myContext) {
      const { context } = args2;
      if (context) context.unshift(myContext);
      else {
        args2.context = [myContext];
      }
    }

    return args2;
  }

  /**
   */
  winstonLogger(category) {
    return this.props.loggers.winstonLogger(this.category(category));
  }

  /**
   */
  get loggers() {
    return this.props.loggers;
  }

  /**
   */
  get parent() {
    return this.props.parent;
  }

  /**
   */
  logger(category) {
    category = this.category(category);
    return new Logger(this, undefined, undefined, category);
  }

  /**
   */
  setLogger(category, logger) {
    category = this.category(category);
    return this.props.loggers.logger(category, logger);
  }

  /**
   */
  child(...args) {
    return new Logger(this, ...args);
  }

  /**
   */
  stack(name) {
    return this.props.loggers.stack(name);
  }

  /**
   */
  start() {
    return this.props.loggers.start();
  }

  /**
   */
  stop() {
    return this.props.loggers.stop();
  }

  /**
   */
  restart() {
    return this.props.loggers.restart();
  }

  /**
   */
  flush() {
    return this.props.loggers.flush();
  }

  /**
   */
  flushCloudWatchTransports() {
    return this.props.loggers.flushCloudWatchTransports();
  }

  /**
   */
  isLevelEnabled(tagsOrNamedParameters, category) {
    return this.props.loggers.isLevelEnabled(
      this.transformArgs(tagsOrNamedParameters, undefined, undefined, undefined, category),
    );
  }

  /**
   */
  levelEnabled(...args) {
    return this.isLevelEnabled(...args);
  }

  /**
   */
  tags(...args) {
    const { loggers, tags } = this.props;
    if (tags) args.unshift(tags);
    return loggers.tags(...args);
  }

  /**
   */
  category(category) {
    return this.props.loggers.category(category, this.props.category);
  }

  /**
   * @ignore
   */
  mergeContext(level, tags, category, ...args) {
    const { loggers, context } = this.props;
    if (context) args.unshift(context);
    return loggers.mergeContext(level, this.tags(tags), this.category(category), ...args);
  }

  /**
   * Accessor for context
   * @returns {object}
   */
  context() {
    return this.props.context;
  }

  /**
   */
  log(...args) {
    this.props.loggers.log(this.transformArgs(...args));
  }
}

module.exports = Loggers;