Source

utils/logger.js

/**
 * Logging helper
 *
 * we use the winston library to simplify logging
 * see: https://github.com/winstonjs/winston for API docs
 *
 * @module utils/logger
 * @copyright 2015–2020 RewardOps Inc.
 */

const winston = require('winston');
const { isEmpty, isPlainObject, isArray, flow, forEach } = require('lodash');
const redact = require('redact-secrets');
const Redactyl = require('redactyl.js');
const { serializeError } = require('serialize-error');

const config = require('../config');
const { LOG_PREFIX } = require('../constants');

const { combine, timestamp: timestampFormatter, printf } = winston.format;

/**
 * Redacted message placeholder value
 *
 * @private
 */
const REDACTED_MESSAGE = '[REDACTED]';

/**
 * Reports whether a value is either an array or object, and therefore
 * can be traversed as valid JSON (i.e. JSON-stringifiable).
 *
 * @param {*} value Any value
 *
 * @returns {boolean} Report of whether value can be traversed as JSON
 *
 * @private
 */
const isJSON = value => isPlainObject(value) || isArray(value);

/**
 * Pretty print a given value.
 *
 * @param {object|Array} json JSON object
 *
 * @returns {string} Prettier JSON string
 *
 * @private
 */
const prettyPrint = json => JSON.stringify(json, undefined, 2);

/**
 * Deeply iterate over an object and redact secret values by replacing
 * them with a `REDACTED_MESSAGE` const string.
 *
 * @param {object} obj Object with potential secrets
 *
 * @returns {object} New object with secrets redacted
 *
 * @private
 */
const redactSecrets = obj => redact(REDACTED_MESSAGE).map(obj);

/**
 * List of Personally Identifiable Information (PII) that we want to filter from the logs.
 */
const PIIAttributes = [
  'Authorization',
  'full_name',
  'email',
  'phone',
  'address',
  'address_2',
  'city',
  'postal_code',
  'country_code',
  'country_subregion_code',
  'ip_address',
];

const redactyl = new Redactyl({
  properties: PIIAttributes,
});

/**
 * Deeply iterate over an object and redact PII values by replacing
 * them with a `REDACTED_MESSAGE` const string.
 *
 * @param {object} data Object with potential PII data
 *
 * @returns {object} New object with PII data redacted
 *
 * @private
 */
const filterLogData = data => {
  if (data instanceof Error) {
    /*
     * serialize Error to plain object to avoid error:
     *
     * `TypeError: Cannot convert object to primitive value`
     */
    data = serializeError(data);
  }

  // TODO: Look for another alternative for format error messages (formatErrorMessage)
  return isPlainObject(data)
    ? flow([rawData => redactSecrets(rawData), redactedData => redactyl.redact(redactedData)])(data)
    : data;
};

/**
 * Filters log data for PII and secrets, then formats the data using the `prettyPrint` function.
 *
 * @param {object} data Unfiltered data object
 *
 * @returns {string} Stringified, filtered data object
 *
 * @private
 */
const processLogData = flow([filterLogData, prettyPrint]);

/**
 * Formats a message according to its type.
 *
 * If given a JSON-stringifiable object, it redacts any potential secrets
 * and pretty prints; otherwise, it simply passes through the message.
 *
 * @param {*} message Log message
 *
 * @returns {string} Formatted message
 * @private
 */
const formatMessage = message => (isJSON(message) ? prettyPrint(redactSecrets(message)) : message);

/**
 * Get Winston log level based on SDK configuration.
 *
 * @returns {string} Log level
 * @private
 */
const getLogLevel = () => {
  if (config.get('verbose')) {
    return 'verbose';
  }
  if (config.get('quiet')) {
    return 'warn';
  }
  return 'info';
};

/**
 * RewardOps log format
 *
 * @param {object} options Log format options
 * @param {string|number} options.level Log level
 * @param {string} options.message Log message
 * @param {string} options.timestamp Log timestamp
 * @param {object} options.meta Logging metadata
 * @returns {string} Formatted log string
 * @private
 */
const logFormat = ({ level, message = '', timestamp, meta = {} }) =>
  [
    `[${timestamp}]`,
    `[${LOG_PREFIX} ${level.toUpperCase()}]`,
    `[${formatMessage(message)}]`,
    isEmpty(meta) ? '' : `\nMetadata:\t${prettyPrint(meta)}`,
  ]
    .join(' ')
    .trim();

/**
 * Create Winston Console Transport
 *
 * @returns Winston Console Transport with configured log level
 * @private
 */
const consoleTransport = new winston.transports.Console({ level: getLogLevel(), silent: config.get('silent') });

/**
 * Winston logger configuration
 *
 * @type {winston.Logger}
 * @private
 */
const logger = winston.createLogger({
  format: combine(timestampFormatter(), printf(logFormat)),
  transports: [consoleTransport],
});

/**
 * Whether the file logger is enabled.
 *
 * @type {boolean}
 * @private
 */
let isFileLoggerEnabled = false;

/**
 * If `logFilePath` is set on {@link module:config}, set log messages to be added to a file.
 *
 * @throws Will throw is `logFilePath` not set
 * @private
 */
const addFileLogger = () => {
  if (config.get('logFilePath')) {
    logger.add(
      new winston.transports.File({
        level: getLogLevel(),
        filename: config.get('logFilePath'),
        maxsize: 100000,
        maxFiles: 20,
      })
    );

    isFileLoggerEnabled = true;
  } else {
    throw Error('You must set `logFilePath` in the config before calling `setLogFilePath`');
  }
};

/**
 * Remove log-to-file configuration for `logger`.
 *
 * @see {@link logger} for more information.
 * @private
 */
const removeFileLogger = () => {
  logger.remove(winston.transports.File);

  isFileLoggerEnabled = false;
};

/**
 * Set/Reset log-to-file configuration for `logger`.
 *
 * @see {@link logger} for more information.
 * @private
 */
const setFileLogger = () => {
  if (isFileLoggerEnabled) {
    removeFileLogger();
  }
  addFileLogger();
};

/**
 * Sets the path for the log file and enable file logging.
 *
 * NOTE: Runs on module initialization.
 *
 * @param {string} path Log file path
 */
function setLogFilePath(path) {
  if (path) {
    config.set('logFilePath', path);
  }

  setFileLogger();
}

/** Flag for whether logger setup has completed (following config module init) */
let isLoggerSetupComplete = false;

/**
 * Update logger setup with Console Transform and optional File Transform.
 *
 * Used following library config initialization to adjust logging, if necessary.
 *
 * @private
 */
const completeLogSetup = () => {
  if (config.isInitialized()) {
    isLoggerSetupComplete = true;

    consoleTransport.level = getLogLevel();
    consoleTransport.silent = config.get('silent');

    // TODO: implement this logic as an event process (triggered by config.init) using the emitter module.
    // This is a breaking change, so it should be a part of the next major release.
    if (config.get('logToFile')) {
      setLogFilePath();
    }
  }
};

/**
 * Reset logger setup.
 *
 * Used for testing purposes.
 *
 * @private
 */
const resetLoggerSetup = () => {
  isLoggerSetupComplete = false;
};

/**
 * Takes a message string and a data object, returning a string with redacted and filtered data.
 *
 * @param {string} message The string template with placeholders.
 * @param {object} data Object containing unfiltered data to be included in the log message.
 *
 * @returns {string} log string with filtered values.
 */
const mergeMessageAndData = (message, data) => {
  let newMessage = message;

  forEach(data, (value, key) => {
    newMessage = newMessage.replace(`{${key}}`, processLogData(value));
  });

  return newMessage;
};

/**
 * Log a message to console in accordance with the SDK configuration
 *
 * NOTE: If an Error object is passed as the message, it will log both the error's
 * message and stack trace.
 *
 * @param {string} message Log message
 * @param {object} options Options object
 * @param {string} options.level Log level
 * @param {object} options.meta Optional log metadata
 * @param {object} options.data Optional log data to be sanitized and replace log placeholders
 *
 * @returns {winston.log|null} Returns a log message, along with optional metadata
 * (if the {@link module:config} option `verbose` is `true`).
 */
function log(message, { level = 'info', meta = {}, data = {} } = {}) {
  if (!isLoggerSetupComplete) {
    completeLogSetup();
  }

  if (message instanceof Error) {
    const error = message;
    message = `${error.message}\n${error.stack}`;
    level = 'error';
  } else if (!isEmpty(data)) {
    message = mergeMessageAndData(message, data);
  }

  return logger.log(level, { message, meta: config.get('verbose') && filterLogData(meta) });
}

module.exports = {
  log,
  logFormat,
  formatMessage,
  getLogLevel,
  setLogFilePath,
  prettyPrint,
  redactSecrets,
  REDACTED_MESSAGE,
  filterLogData,
  processLogData,
  resetLoggerSetup,
};