UNPKG

@interaktiv/mibuilder-core

Version:

Core libraries to interact with MiBuilder projects.

614 lines (507 loc) 19 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.Logger = exports.isValidLevel = exports.DEFAULT_LEVEL = exports.ROOT_NAME = exports.LEVEL_SILLY = exports.LEVEL_DEBUG = exports.LEVEL_VERBOSE = exports.LEVEL_HTTP = exports.LEVEL_INFO = exports.LEVEL_WARN = exports.LEVEL_ERROR = exports.getLevels = void 0; var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")); var _os = require("os"); var _path = _interopRequireDefault(require("path")); var _stream = _interopRequireDefault(require("stream")); var _dxl = require("@interaktiv/dxl"); var _json = require("@interaktiv/json"); var _types = require("@interaktiv/types"); var _debug = _interopRequireDefault(require("debug")); var _winston = require("winston"); var _globalConfig = require("../config/global-config"); var _constants = require("../config/constants"); var _mibuilderError = require("../mibuilder-error"); var _winstonMemory = require("./winston-memory"); // Ok to log clientid const FILTERED_KEYS = ['sid', 'Authorization', // Any json attribute that contains the words "access" and "token" will have the attribute/value hidden { name: 'access_token', regex: 'access[^\'"]*token' }, // Any json attribute that contains the words "refresh" and "token" will have the attribute/value hidden { name: 'refresh_token', regex: 'refresh[^\'"]*token' }, 'clientsecret', // Any json attribute that contains the words "auth", and "url" will have the attribute/value hidden { name: 'authurl', regex: '[^\'"]*auth[^\'"]*url' }]; /** * Standard `Logger` levels. */ const { levels } = _winston.config.npm; const getLevels = () => (0, _types.keysOf)(levels); exports.getLevels = getLevels; const LEVEL_ERROR = 'error'; exports.LEVEL_ERROR = LEVEL_ERROR; const LEVEL_WARN = 'warn'; exports.LEVEL_WARN = LEVEL_WARN; const LEVEL_INFO = 'info'; exports.LEVEL_INFO = LEVEL_INFO; const LEVEL_HTTP = 'http'; exports.LEVEL_HTTP = LEVEL_HTTP; const LEVEL_VERBOSE = 'verbose'; exports.LEVEL_VERBOSE = LEVEL_VERBOSE; const LEVEL_DEBUG = 'debug'; exports.LEVEL_DEBUG = LEVEL_DEBUG; const LEVEL_SILLY = 'silly'; /** * The name of the root mibuilder `Logger`. */ exports.LEVEL_SILLY = LEVEL_SILLY; const ROOT_NAME = 'mibuilder'; /** * The default `LoggerLevel` when constructing new `Logger` instances. */ exports.ROOT_NAME = ROOT_NAME; const DEFAULT_LEVEL = LEVEL_WARN; exports.DEFAULT_LEVEL = DEFAULT_LEVEL; const HIDDEN = 'HIDDEN'; const filterData = info => { return Object.entries(info).map(([key, value]) => { if (!value) return [key, value]; // Normalize all objects into a string. This include errors. let arg; if (value instanceof Buffer) { arg = '<Buffer>'; } else if ((0, _types.isObject)(value)) { arg = JSON.stringify(value); } else if ((0, _types.isString)(value)) { arg = value; } else { arg = ''; } FILTERED_KEYS.forEach(filteredKey => { let expElement = filteredKey; let expName = filteredKey; // Filtered keys can be strings or objects containing regular expression components. if ((0, _types.isPlainObject)(filteredKey)) { expElement = filteredKey.regex; expName = filteredKey.name; } const hiddenAttrMessage = `"<${expName} - ${HIDDEN}>"`; // Match all json attribute values case insensitive: ex. {" Access*^&(*()^* Token " : " 45143075913458901348905 \n\t" ...} // eslint-disable-next-line security/detect-non-literal-regexp const regexTokens = new RegExp(`(['"][^'"]*${expElement}[^'"]*['"]\\s*:\\s*)['"][^'"]*['"]`, 'gi'); arg = arg.replace(regexTokens, `$1${hiddenAttrMessage}`); // Match all key value attribute case insensitive: ex. {" key\t" : ' access_token ' , " value " : " dsafgasr431 " ....} // eslint-disable-next-line security/detect-non-literal-regexp const keyRegex = new RegExp(`(['"]\\s*key\\s*['"]\\s*:)\\s*['"]\\s*${expElement}\\s*['"]\\s*.\\s*['"]\\s*value\\s*['"]\\s*:\\s*['"]\\s*[^'"]*['"]`, 'gi'); arg = arg.replace(keyRegex, `$1${hiddenAttrMessage}`); }); arg = arg.replace(/sid=(.*)/, `sid=<${HIDDEN}>`); // Return an object if an object was logged; otherwise return the filtered string. if ((0, _types.isObject)(value)) return [key, JSON.parse(arg)]; return [key, arg]; }).reduce((acc, [key, value]) => (0, _extends2.default)({}, acc, { [key]: value }), {}); }; const isValidLevel = (level = '') => { if (!(0, _types.isString)(level) || !(0, _types.isKeyOf)(levels, level)) return false; return true; }; exports.isValidLevel = isValidLevel; let rootLogger; /** * A logging abstraction powered by {@link https://github.com/winstonjs/winston} that provides both a default * logger configuration that will log to `mibuilder.log`, and a way to create custom loggers based on the same foundation. * * ``` * // Gets the root mibuilder logger * const logger = await Logger.root(); * * // Creates a child logger of the root mibuilder logger with custom fields applied * const childLogger = await Logger.child('myRootChild', {tag: 'value'}); * * // Creates a custom logger unaffiliated with the root logger * const myCustomLogger = new Logger('myCustomLogger'); * * // Creates a child of a custom logger unaffiliated with the root logger with custom fields applied * const myCustomChildLogger = myCustomLogger.child('myCustomChild', {tag: 'value'}); * ``` * **See** https://github.com/winstonjs/winstonjs * * @param {String|Object} optionsOrName - The logger namespace or whole configuration as object */ class Logger { /** * Gets the root logger with the default level and file stream. * * @return {Logger} The root logger */ static async root() { if (rootLogger) return rootLogger; rootLogger = new Logger(ROOT_NAME); rootLogger.setLevel(); // Disable log file writing, if applicable if (_dxl.env.getBoolean('MIBUILDER_DISABLE_LOG_FILE') !== true && (0, _globalConfig.getEnvironmentMode)() !== _globalConfig.MODE_TEST) { await rootLogger.addLogFileStream(_constants.LOG_FILE_PATH); } rootLogger.trace(`Created root '${rootLogger.getName()}' logger instance`); // The debug library does this for you, but no point setting up the stream if it isn't there if (_dxl.env.getBoolean('DEBUG')) { const debuggers = {}; debuggers.core = (0, _debug.default)(`${rootLogger.getName()}:core`); rootLogger.addStream({ name: 'debug', stream: new _stream.default.Writable({ write: (chunk, encoding, next) => { const json = (0, _json.parseJsonMap)(chunk.toString()); let debuggerName = 'core'; if ((0, _types.isString)(json.label) || (0, _types.isString)(json.log)) { debuggerName = json.label || json.log; if (debuggers[debuggerName] == null) { debuggers[debuggerName] = (0, _debug.default)(`${rootLogger.getName()}:${debuggerName}`); } } const debug = debuggers[debuggerName]; if ((0, _types.isFunction)(debug) && json.level === LEVEL_DEBUG) debug(json.message); next(); } }), // Consume all levels level: LEVEL_SILLY }); } return rootLogger; } /** * Create a child of the root logger, inheriting this instance's configuration such as `level`, `streams`, etc. * * @param {String} name The name of the child logger. * @param {Object} [metaData] Additional fields included in all log lines. */ static async child(name, metaData) { return (await this.root()).child(name, metaData); } /** * Destroys the root `Logger`. */ static destroyRoot() { if (!rootLogger) return; rootLogger.close(); rootLogger = undefined; } constructor(optionsOrName) { this._filters = []; this._name = 'logger'; let options; if ((0, _types.isString)(optionsOrName)) { options = { level: DEFAULT_LEVEL, format: _winston.format.combine(...[this._applyFilters(), rootLogger ? _winston.format.label({ label: optionsOrName }) : null, _winston.format.splat(), _winston.format.errors({ stack: true }), _winston.format.timestamp(), _winston.format.json()].filter(Boolean)), defaultMeta: { name: optionsOrName, pid: process.pid }, exitOnError: false, transports: [] }; this._name = optionsOrName; } else { options = optionsOrName; this._name = options.name; } if (rootLogger && this._name === ROOT_NAME) { throw new _mibuilderError.MiBuilderError('RedundantRootLogger'); } this.winston = (0, _winston.createLogger)(options); // All loggers must filter sensitive data this.addFilter((...args) => filterData(...args)); } // Custom format to run any log through filters and return final result _applyFilters() { return (0, _winston.format)((info, opts) => { const filteredInfo = this._filters.reduce((acc, filter) => (0, _extends2.default)({}, acc, filter(acc, opts)), info); return filteredInfo; })(); } /** * Adds a stream. * * For options {@see transports.Stream} * * @param {Object} options The stream configuration to add * @param {Object<Stream>} [options.name=stream] The name for the stream. * @param {Object<Stream>} options.stream The stream configuration to add. * @param {String} options.level The default level of the stream. */ addStream(options) { if ((0, _types.isObject)(options) === false) return; const newStreamTransport = new _winston.transports.Stream(options); newStreamTransport.name = options.name || 'stream'; this.winston.add(newStreamTransport); } /** * Adds a file stream to this logger. Resolved or rejected upon completion of * the addition. * * @param {String} logFile The path to the log file. If it doesn't exist it will be created. */ async addLogFileStream(logFile) { // Check if we have write access to the log file (i.e., we created it // already) try { await (0, _dxl.access)(logFile, _dxl.constants.W_OK); } catch (err1) { await (0, _dxl.mkdirp)(_path.default.dirname(logFile)); await (0, _dxl.writeFile)(logFile, ''); } // Avoid multiple streams to same log file const streamAlreadyAdded = this.winston.transports.filter(transport => (0, _types.isClassAssignableTo)(transport, _winston.transports.File)).find(transport => transport.filename === logFile) != null; if (streamAlreadyAdded) return this; // TODO: rotating-file // https://github.com/trentm/node-bunyan#stream-type-rotating-file this.winston.add(new _winston.transports.File({ filename: logFile, level: this.winston.level })); return this; } /** * Sets the name for this logger. * * @param {String} newName The new name for this logger * @return {String} name Returns the name of this logger */ setName(newName) { if (!(0, _types.isString)(newName)) return this._name; this._name = newName; this.addMetaData('label', this._name); return this._name; } /** * Gets the name of this logger. * * @return {String} The name of the current logger */ getName() { return this._name; } /** * Gets the current level of this logger. * * @return {String} The log level of the current logger */ getLevel() { return this.winston.level; } /** * Set the logging level of all streams for this logger. If a specific `level` is not provided, this method will * attempt to read it from the environment variable `<BIN>_LOG_LEVEL`, and if not found, * {@link DEFAULT_LEVEL} will be used instead. For convenience `this` object is returned. * * ``` * // Sets the level from the environment or default value * logger.setLevel() * * // Set the level from the INFO enum * logger.setLevel(LEVEL_INFO) * * // Sets the level case-insensitively from a string value * logger.setLevel(Logger.getLevelByName('info')) * ``` * * @param {LEVEL_<VALUE>} [level=DEFAULT_LEVEL] The logger level. * @return {Logger} this */ setLevel(level = _dxl.env.getString('MIBUILDER_LOG_LEVEL', DEFAULT_LEVEL)) { let newLevel = level; if (isValidLevel(newLevel) === false) newLevel = DEFAULT_LEVEL; if (this.winston.level === newLevel) return this; this.winston.level = newLevel; this.winston.transports.filter(transport => transport.name !== 'debug').forEach(transport => { // eslint-disable-next-line no-param-reassign transport.level = newLevel; }); return this; } /** * Compares the requested log level with the current log level. Returns true if * the requested log level is greater than or equal to the current log level. * * @param {string} level The requested log level to compare against the currently set log level. * @return {boolean} Should we log */ shouldLog(level) { return this.winston.isLevelEnabled(level); } /** * Gets the underlying winston logger. * * @return {Object<winston.logger>} The winston logger object */ getWinstonLogger() { return this.winston; } /** * Use in-memory logging for this logger instance instead of any parent * streams. Useful for testing. * * **WARNING: This cannot be undone for this logger instance.** * * @return {Object<Logger>} Returns this for chaining */ useMemoryLogging() { // Remove all previous transports this.winston.clear(); this.winston.add(new _winstonMemory.Memory({ limit: 5000, level: this.winston.level })); return this; } /** * Gets an array of log line objects. Each element is an object that * corresponds to a log line. * * @return {Object[]} The buffered log line objects */ getBufferedRecords() { const memoryTransport = this.winston.transports.find(transport => (0, _types.isClassAssignableTo)(transport, _winstonMemory.Memory)); return (0, _types.getArray)(memoryTransport, 'records', []); } /** * Reads a text blob of all the log lines contained in memory or the log file. * * @return {String} The log contents as text */ async readLogContentsAsText() { const bufferedRecords = this.getBufferedRecords(); if ((0, _types.isEmpty)(bufferedRecords) === false) { return bufferedRecords.reduce((content, line) => `${content}${JSON.stringify(line)}${_os.EOL}`, ''); } try { const results = await Promise.all(this.winston.transports.filter(transport => (0, _types.isClassAssignableTo)(transport, _winston.transports.File)).map(transport => (0, _dxl.readFile)(transport.filename, 'utf8'))); return results.join(''); } catch (_err) { return ''; } } /** * Adds a filter to be applied to all logged messages. * * @param {Function} filter A function with signature `(info: object, opts) => object` that transforms log message arguments. */ addFilter(filter) { this._filters = (0, _types.getArray)(this, '_filters', []); this._filters.push(filter); } setFilters(newFilters) { if ((0, _types.isArray)(newFilters) === false) return this._filters; this._filters = newFilters; return this._filters; } /** * Close the logger, including any streams, and remove all listeners. * * @param {Function} fn A function with signature `(stream: transports) => void` to call for each stream with the stream as an arg. */ close(fn) { if ((0, _types.isFunction)(fn)) { this.winston.transports.forEach(transport => fn(transport)); } this.winston.close(); } /** * Create a child logger, typically to add a few log record fields. For convenience this object is returned. * * @param {String} childName The name of the child logger that is emitted w/ log line as `log:<name>`. * @param {Object} [metaData={}] Additional fields included in all log lines for the child logger. * @return {Logger} The created child logger */ child(childName, metaData = {}) { if (!childName) throw new Error('A logger name is required'); const childLogger = new Logger(childName); // Only support including additional fields on log line (no config) childLogger.winston = this.winston.child((0, _extends2.default)({}, metaData, { label: childName, pid: process.pid })); childLogger.setFilters(this._filters); this.trace(`Setup child '${childName}' logger instance`); return childLogger; } /** * Add a field to all log lines for this logger. For convenience `this` object is returned. * * @param {String} key The name of the field to add. * @param {*} value The value of the field to be logged. * @return {Logger} this */ addMetaData(key, value) { this.winston.defaultMeta[key] = value; return this; } /** * Logs at `silly` level with filtering applied. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ silly(...args) { this.winston.silly(...args); return this; } trace(...args) { return this.silly(...args); } /** * Logs at `debug` level with filtering applied. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ debug(...args) { this.winston.debug(...args); return this; } /** * Logs at `info` level with filtering applied. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ info(...args) { this.winston.info(...args); return this; } log(...args) { this.winston.info(...args); return this; } /** * Logs at `warn` level with filtering applied. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ warn(...args) { this.winston.warn(...args); return this; } /** * Logs at `error` level with filtering applied. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ error(...args) { this.winston.error(...args); return this; } /** * Logs at `fatal` level. For convenience `this` object is returned. * * @param {*} args Any number of arguments to be logged. * @return {Logger} this */ fatal(...args) { // Always show fatal to stderr // eslint-disable-next-line no-console console.error(...args); this.winston.error(...args); return this; } } exports.Logger = Logger;