UNPKG

@salesforce/core

Version:

Core libraries to interact with SFDX projects, orgs, and APIs.

722 lines 28.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Logger = exports.LoggerFormat = exports.LoggerLevel = void 0; /* * Copyright (c) 2020, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ const events_1 = require("events"); const os = require("os"); const path = require("path"); const stream_1 = require("stream"); // @ts-ignore const Bunyan = require("@salesforce/bunyan"); const kit_1 = require("@salesforce/kit"); const ts_types_1 = require("@salesforce/ts-types"); const Debug = require("debug"); const global_1 = require("./global"); const sfdxError_1 = require("./sfdxError"); const fs_1 = require("./util/fs"); /** * Standard `Logger` levels. * * **See** {@link https://github.com/forcedotcom/node-bunyan#levels|Bunyan Levels} */ var LoggerLevel; (function (LoggerLevel) { LoggerLevel[LoggerLevel["TRACE"] = 10] = "TRACE"; LoggerLevel[LoggerLevel["DEBUG"] = 20] = "DEBUG"; LoggerLevel[LoggerLevel["INFO"] = 30] = "INFO"; LoggerLevel[LoggerLevel["WARN"] = 40] = "WARN"; LoggerLevel[LoggerLevel["ERROR"] = 50] = "ERROR"; LoggerLevel[LoggerLevel["FATAL"] = 60] = "FATAL"; })(LoggerLevel = exports.LoggerLevel || (exports.LoggerLevel = {})); /** * `Logger` format types. */ var LoggerFormat; (function (LoggerFormat) { LoggerFormat[LoggerFormat["JSON"] = 0] = "JSON"; LoggerFormat[LoggerFormat["LOGFMT"] = 1] = "LOGFMT"; })(LoggerFormat = exports.LoggerFormat || (exports.LoggerFormat = {})); /** * A logging abstraction powered by {@link https://github.com/forcedotcom/node-bunyan|Bunyan} that provides both a default * logger configuration that will log to `sfdx.log`, and a way to create custom loggers based on the same foundation. * * ``` * // Gets the root sfdx logger * const logger = await Logger.root(); * * // Creates a child logger of the root sfdx 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/forcedotcom/node-bunyan * * **See** https://developer.salesforce.com/docs/atlas.en-us.sfdx_setup.meta/sfdx_setup/sfdx_dev_cli_log_messages.htm */ class Logger { /** * Constructs a new `Logger`. * * @param optionsOrName A set of `LoggerOptions` or name to use with the default options. * * **Throws** *{@link SfdxError}{ name: 'RedundantRootLogger' }* More than one attempt is made to construct the root * `Logger`. */ constructor(optionsOrName) { /** * The default rotation period for logs. Example '1d' will rotate logs daily (at midnight). * See 'period' docs here: https://github.com/forcedotcom/node-bunyan#stream-type-rotating-file */ this.logRotationPeriod = new kit_1.Env().getString('SFDX_LOG_ROTATION_PERIOD') || '1d'; /** * The number of backup rotated log files to keep. * Example: '3' will have the base sfdx.log file, and the past 3 (period) log files. * See 'count' docs here: https://github.com/forcedotcom/node-bunyan#stream-type-rotating-file */ this.logRotationCount = new kit_1.Env().getNumber('SFDX_LOG_ROTATION_COUNT') || 2; /** * Whether debug is enabled for this Logger. */ this.debugEnabled = false; this.uncaughtExceptionHandler = (err) => { // W-7558552 // Only log uncaught exceptions in root logger if (this === Logger.rootLogger) { // log the exception // FIXME: good chance this won't be logged because // process.exit was called before this is logged // https://github.com/trentm/node-bunyan/issues/95 this.fatal(err); } }; this.exitHandler = () => { this.close(); }; let options; if (typeof optionsOrName === 'string') { options = { name: optionsOrName, level: Logger.DEFAULT_LEVEL, serializers: Bunyan.stdSerializers, }; } else { options = optionsOrName; } if (Logger.rootLogger && options.name === Logger.ROOT_NAME) { throw new sfdxError_1.SfdxError('RedundantRootLogger'); } // Inspect format to know what logging format to use then delete from options to // ensure it doesn't conflict with Bunyan. this.format = options.format || LoggerFormat.JSON; delete options.format; // If the log format is LOGFMT, we need to convert any stream(s) into a LOGFMT type stream. if (this.format === LoggerFormat.LOGFMT && options.stream) { const ls = this.createLogFmtFormatterStream({ stream: options.stream }); options.stream = ls.stream; } if (this.format === LoggerFormat.LOGFMT && options.streams) { const logFmtConvertedStreams = []; options.streams.forEach((ls) => { logFmtConvertedStreams.push(this.createLogFmtFormatterStream(ls)); }); options.streams = logFmtConvertedStreams; } this.bunyan = new Bunyan(options); this.bunyan.name = options.name; this.bunyan.filters = []; if (!options.streams && !options.stream) { this.bunyan.streams = []; } // all SFDX loggers must filter sensitive data this.addFilter((...args) => _filter(...args)); if (global_1.Global.getEnvironmentMode() !== global_1.Mode.TEST) { Logger.lifecycle.on('uncaughtException', this.uncaughtExceptionHandler); Logger.lifecycle.on('exit', this.exitHandler); } this.trace(`Created '${this.getName()}' logger instance`); } /** * Gets the root logger with the default level, file stream, and DEBUG enabled. */ static async root() { if (this.rootLogger) { return this.rootLogger; } const rootLogger = (this.rootLogger = new Logger(Logger.ROOT_NAME).setLevel()); // disable log file writing, if applicable if (process.env.SFDX_DISABLE_LOG_FILE !== 'true' && global_1.Global.getEnvironmentMode() !== global_1.Mode.TEST) { await rootLogger.addLogFileStream(global_1.Global.LOG_FILE_PATH); } rootLogger.enableDEBUG(); return rootLogger; } /** * Gets the root logger with the default level, file stream, and DEBUG enabled. */ static getRoot() { if (this.rootLogger) { return this.rootLogger; } const rootLogger = (this.rootLogger = new Logger(Logger.ROOT_NAME).setLevel()); // disable log file writing, if applicable if (process.env.SFDX_DISABLE_LOG_FILE !== 'true' && global_1.Global.getEnvironmentMode() !== global_1.Mode.TEST) { rootLogger.addLogFileStreamSync(global_1.Global.LOG_FILE_PATH); } rootLogger.enableDEBUG(); return rootLogger; } /** * Destroys the root `Logger`. * * @ignore */ static destroyRoot() { if (this.rootLogger) { this.rootLogger.close(); this.rootLogger = undefined; } } /** * Create a child of the root logger, inheriting this instance's configuration such as `level`, `streams`, etc. * * @param name The name of the child logger. * @param fields Additional fields included in all log lines. */ static async child(name, fields) { return (await Logger.root()).child(name, fields); } /** * Create a child of the root logger, inheriting this instance's configuration such as `level`, `streams`, etc. * * @param name The name of the child logger. * @param fields Additional fields included in all log lines. */ static childFromRoot(name, fields) { return Logger.getRoot().child(name, fields); } /** * Gets a numeric `LoggerLevel` value by string name. * * @param {string} levelName The level name to convert to a `LoggerLevel` enum value. * * **Throws** *{@link SfdxError}{ name: 'UnrecognizedLoggerLevelName' }* The level name was not case-insensitively recognized as a valid `LoggerLevel` value. * @see {@Link LoggerLevel} */ static getLevelByName(levelName) { levelName = levelName.toUpperCase(); if (!ts_types_1.isKeyOf(LoggerLevel, levelName)) { throw new sfdxError_1.SfdxError('UnrecognizedLoggerLevelName'); } return LoggerLevel[levelName]; } /** * Adds a stream. * * @param stream The stream configuration to add. * @param defaultLevel The default level of the stream. */ addStream(stream, defaultLevel) { if (this.format === LoggerFormat.LOGFMT) { stream = this.createLogFmtFormatterStream(stream); } this.bunyan.addStream(stream, defaultLevel); } /** * Adds a file stream to this logger. Resolved or rejected upon completion of the addition. * * @param logFile The path to the log file. If it doesn't exist it will be created. */ async addLogFileStream(logFile) { try { // Check if we have write access to the log file (i.e., we created it already) await fs_1.fs.access(logFile, fs_1.fs.constants.W_OK); } catch (err1) { try { await fs_1.fs.mkdirp(path.dirname(logFile), { mode: fs_1.fs.DEFAULT_USER_DIR_MODE, }); } catch (err2) { // noop; directory exists already } try { await fs_1.fs.writeFile(logFile, '', { mode: fs_1.fs.DEFAULT_USER_FILE_MODE }); } catch (err3) { throw sfdxError_1.SfdxError.wrap(err3); } } // avoid multiple streams to same log file if (!this.bunyan.streams.find( // No bunyan typings // eslint-disable-next-line @typescript-eslint/no-explicit-any (stream) => stream.type === 'rotating-file' && stream.path === logFile)) { this.addStream({ type: 'rotating-file', path: logFile, period: this.logRotationPeriod, count: this.logRotationCount, level: this.bunyan.level(), }); } } /** * Adds a file stream to this logger. Resolved or rejected upon completion of the addition. * * @param logFile The path to the log file. If it doesn't exist it will be created. */ addLogFileStreamSync(logFile) { try { // Check if we have write access to the log file (i.e., we created it already) fs_1.fs.accessSync(logFile, fs_1.fs.constants.W_OK); } catch (err1) { try { fs_1.fs.mkdirpSync(path.dirname(logFile), { mode: fs_1.fs.DEFAULT_USER_DIR_MODE, }); } catch (err2) { // noop; directory exists already } try { fs_1.fs.writeFileSync(logFile, '', { mode: fs_1.fs.DEFAULT_USER_FILE_MODE }); } catch (err3) { throw sfdxError_1.SfdxError.wrap(err3); } } // avoid multiple streams to same log file if (!this.bunyan.streams.find( // No bunyan typings // eslint-disable-next-line @typescript-eslint/no-explicit-any (stream) => stream.type === 'rotating-file' && stream.path === logFile)) { this.addStream({ type: 'rotating-file', path: logFile, period: this.logRotationPeriod, count: this.logRotationCount, level: this.bunyan.level(), }); } } /** * Gets the name of this logger. */ getName() { return this.bunyan.name; } /** * Gets the current level of this logger. */ getLevel() { return this.bunyan.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 `SFDX_LOG_LEVEL`, and if not found, * {@link Logger.DEFAULT_LOG_LEVEL} will be used instead. For convenience `this` object is returned. * * @param {LoggerLevelValue} [level] The logger level. * * **Throws** *{@link SfdxError}{ name: 'UnrecognizedLoggerLevelName' }* A value of `level` read from `SFDX_LOG_LEVEL` * was invalid. * * ``` * // Sets the level from the environment or default value * logger.setLevel() * * // Set the level from the INFO enum * logger.setLevel(LoggerLevel.INFO) * * // Sets the level case-insensitively from a string value * logger.setLevel(Logger.getLevelByName('info')) * ``` */ setLevel(level) { if (level == null) { level = process.env.SFDX_LOG_LEVEL ? Logger.getLevelByName(process.env.SFDX_LOG_LEVEL) : Logger.DEFAULT_LEVEL; } this.bunyan.level(level); return this; } /** * Gets the underlying Bunyan logger. */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types getBunyanLogger() { return this.bunyan; } /** * 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 level The requested log level to compare against the currently set log level. */ shouldLog(level) { if (typeof level === 'string') { level = Bunyan.levelFromName(level); } return level >= this.getLevel(); } /** * Use in-memory logging for this logger instance instead of any parent streams. Useful for testing. * For convenience this object is returned. * * **WARNING: This cannot be undone for this logger instance.** */ useMemoryLogging() { this.bunyan.streams = []; this.bunyan.ringBuffer = new Bunyan.RingBuffer({ limit: 5000 }); this.addStream({ type: 'raw', stream: this.bunyan.ringBuffer, level: this.bunyan.level(), }); return this; } /** * Gets an array of log line objects. Each element is an object that corresponds to a log line. */ getBufferedRecords() { if (this.bunyan.ringBuffer) { return this.bunyan.ringBuffer.records; } return []; } /** * Reads a text blob of all the log lines contained in memory or the log file. */ readLogContentsAsText() { if (this.bunyan.ringBuffer) { return this.getBufferedRecords().reduce((accum, line) => { accum += JSON.stringify(line) + os.EOL; return accum; }, ''); } else { let content = ''; // No bunyan typings // eslint-disable-next-line @typescript-eslint/no-explicit-any this.bunyan.streams.forEach(async (stream) => { if (stream.type === 'file') { content += await fs_1.fs.readFile(stream.path, 'utf8'); } }); return content; } } /** * Adds a filter to be applied to all logged messages. * * @param filter A function with signature `(...args: any[]) => any[]` that transforms log message arguments. */ addFilter(filter) { // eslint disable-line @typescript-eslint/no-explicit-any if (!this.bunyan.filters) { this.bunyan.filters = []; } this.bunyan.filters.push(filter); } /** * Close the logger, including any streams, and remove all listeners. * * @param fn A function with signature `(stream: LoggerStream) => void` to call for each stream with * the stream as an arg. */ close(fn) { if (this.bunyan.streams) { try { this.bunyan.streams.forEach((entry) => { if (fn) { fn(entry); } // close file streams, flush buffer to disk // eslint-disable-next-line @typescript-eslint/unbound-method if (entry.type === 'file' && entry.stream && ts_types_1.isFunction(entry.stream.end)) { entry.stream.end(); } }); } finally { Logger.lifecycle.removeListener('uncaughtException', this.uncaughtExceptionHandler); Logger.lifecycle.removeListener('exit', this.exitHandler); } } } /** * Create a child logger, typically to add a few log record fields. For convenience this object is returned. * * @param name The name of the child logger that is emitted w/ log line as `log:<name>`. * @param fields Additional fields included in all log lines for the child logger. */ child(name, fields = {}) { if (!name) { throw new sfdxError_1.SfdxError('LoggerNameRequired'); } fields.log = name; const child = new Logger(name); // only support including additional fields on log line (no config) child.bunyan = this.bunyan.child(fields, true); child.bunyan.name = name; child.bunyan.filters = this.bunyan.filters; this.trace(`Setup child '${name}' logger instance`); return child; } /** * Add a field to all log lines for this logger. For convenience `this` object is returned. * * @param name The name of the field to add. * @param value The value of the field to be logged. */ addField(name, value) { this.bunyan.fields[name] = value; return this; } /** * Logs at `trace` level with filtering applied. For convenience `this` object is returned. * * @param args Any number of arguments to be logged. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any trace(...args) { this.bunyan.trace(this.applyFilters(LoggerLevel.TRACE, ...args)); return this; } /** * Logs at `debug` level with filtering applied. For convenience `this` object is returned. * * @param args Any number of arguments to be logged. */ debug(...args) { this.bunyan.debug(this.applyFilters(LoggerLevel.DEBUG, ...args)); return this; } /** * Logs at `debug` level with filtering applied. * * @param cb A callback that returns on array objects to be logged. */ debugCallback(cb) { if (this.getLevel() === LoggerLevel.DEBUG || process.env.DEBUG) { const result = cb(); if (ts_types_1.isArray(result)) { this.bunyan.debug(this.applyFilters(LoggerLevel.DEBUG, ...result)); } else { this.bunyan.debug(this.applyFilters(LoggerLevel.DEBUG, ...[result])); } } } /** * Logs at `info` level with filtering applied. For convenience `this` object is returned. * * @param args Any number of arguments to be logged. */ info(...args) { this.bunyan.info(this.applyFilters(LoggerLevel.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. */ warn(...args) { this.bunyan.warn(this.applyFilters(LoggerLevel.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. */ error(...args) { this.bunyan.error(this.applyFilters(LoggerLevel.ERROR, ...args)); return this; } /** * Logs at `fatal` level with filtering applied. For convenience `this` object is returned. * * @param args Any number of arguments to be logged. */ fatal(...args) { // always show fatal to stderr // eslint-disable-next-line no-console console.error(...args); this.bunyan.fatal(this.applyFilters(LoggerLevel.FATAL, ...args)); return this; } /** * Enables logging to stdout when the DEBUG environment variable is used. It uses the logger * name as the debug name, so you can do DEBUG=<logger-name> to filter the results to your logger. */ enableDEBUG() { // The debug library does this for you, but no point setting up the stream if it isn't there if (process.env.DEBUG && !this.debugEnabled) { const debuggers = {}; debuggers.core = Debug(`${this.getName()}:core`); this.addStream({ name: 'debug', stream: new stream_1.Writable({ write: (chunk, encoding, next) => { try { const json = kit_1.parseJsonMap(chunk.toString()); const logLevel = ts_types_1.ensureNumber(json.level); if (this.getLevel() <= logLevel) { let debuggerName = 'core'; if (ts_types_1.isString(json.log)) { debuggerName = json.log; if (!debuggers[debuggerName]) { debuggers[debuggerName] = Debug(`${this.getName()}:${debuggerName}`); } } const level = LoggerLevel[logLevel]; ts_types_1.ensure(debuggers[debuggerName])(`${level} ${json.msg}`); } } catch (err) { // do nothing } next(); }, }), // Consume all levels level: 0, }); this.debugEnabled = true; } } applyFilters(logLevel, ...args) { if (this.shouldLog(logLevel)) { // No bunyan typings // eslint-disable-next-line @typescript-eslint/no-explicit-any this.bunyan.filters.forEach((filter) => (args = filter(...args))); } return args && args.length === 1 ? args[0] : args; } createLogFmtFormatterStream(loggerStream) { const logFmtWriteableStream = new stream_1.Writable({ write: (chunk, enc, cb) => { try { const parsedJSON = JSON.parse(chunk.toString()); const keys = Object.keys(parsedJSON); let logEntry = ''; keys.forEach((key) => { let logMsg = `${parsedJSON[key]}`; if (logMsg.trim().includes(' ')) { logMsg = `"${logMsg}"`; } logEntry += `${key}=${logMsg} `; }); if (loggerStream.stream) { loggerStream.stream.write(logEntry.trimRight() + '\n'); } } catch (error) { if (loggerStream.stream) { loggerStream.stream.write(chunk.toString()); } } cb(null); }, }); return Object.assign({}, loggerStream, { stream: logFmtWriteableStream }); } } exports.Logger = Logger; /** * The name of the root sfdx `Logger`. */ Logger.ROOT_NAME = 'sfdx'; /** * The default `LoggerLevel` when constructing new `Logger` instances. */ Logger.DEFAULT_LEVEL = LoggerLevel.WARN; /** * A list of all lower case `LoggerLevel` names. * * **See** {@link LoggerLevel} */ Logger.LEVEL_NAMES = Object.values(LoggerLevel) .filter(ts_types_1.isString) .map((v) => v.toLowerCase()); // Rollup all instance-specific process event listeners together to prevent global `MaxListenersExceededWarning`s. Logger.lifecycle = (() => { const events = new events_1.EventEmitter(); events.setMaxListeners(0); // never warn on listener counts process.on('uncaughtException', (err) => events.emit('uncaughtException', err)); process.on('exit', () => events.emit('exit')); return events; })(); // 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 "sfdx", "auth", and "url" will have the attribute/value hidden { name: 'sfdxauthurl', regex: 'sfdx[^\'"]*auth[^\'"]*url' }, ]; // SFDX code and plugins should never show tokens or connect app information in the logs const _filter = (...args) => { return args.map((arg) => { if (ts_types_1.isArray(arg)) { return _filter(...arg); } if (arg) { let _arg; // Normalize all objects into a string. This include errors. if (arg instanceof Buffer) { _arg = '<Buffer>'; } else if (ts_types_1.isObject(arg)) { _arg = JSON.stringify(arg); } else if (ts_types_1.isString(arg)) { _arg = arg; } else { _arg = ''; } const HIDDEN = 'HIDDEN'; FILTERED_KEYS.forEach((key) => { let expElement = key; let expName = key; // Filtered keys can be strings or objects containing regular expression components. if (ts_types_1.isPlainObject(key)) { expElement = key.regex; expName = key.name; } const hiddenAttrMessage = `"<${expName} - ${HIDDEN}>"`; // Match all json attribute values case insensitive: ex. {" Access*^&(*()^* Token " : " 45143075913458901348905 \n\t" ...} 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 " ....} 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(/(00D\w{12,15})![.\w]*/, `<${HIDDEN}>`); // return an object if an object was logged; otherwise return the filtered string. return ts_types_1.isObject(arg) ? kit_1.parseJson(_arg) : _arg; } else { return arg; } }); }; //# sourceMappingURL=logger.js.map