import abind from 'abind' import stringify from 'json-stringify-safe' import stackTrace from 'stack-trace' import Utils from '../Util/Utils.js' /** * Enumeration of log levels. * @enum {string} * @property {string} DEBUG - Debug log level. * @property {string} INFO - Info log level. * @property {string} WARN - Warning log level. * @property {string} ERROR - Error log level. */ export enum LOG_LEVELS { DEBUG = 'DEBUG', INFO = 'INFO', WARN = 'WARN', ERROR = 'ERROR', } const LOG_LEVEL_RANK: Record = { [LOG_LEVELS.DEBUG]: 0, [LOG_LEVELS.INFO]: 1, [LOG_LEVELS.WARN]: 2, [LOG_LEVELS.ERROR]: 3, } /** * A constant that represents the console object to be used for logging. * If the console object has a property named 'notGlobalLogger', it is assumed * to be a custom logger and the 'origin' property is used as the console object. * Otherwise, the global console object is used. * @type {Console} */ const PURE_CONSOLE = console['notGlobalLogger'] ? console['origin'] : console /** * The default log function that is used for logging messages. * @type {Function} */ const DEFAULT_LOG_FUNCTION = PURE_CONSOLE.log.bind(PURE_CONSOLE) /** * Creates a blacklist array by mapping each string in the given array to its lowercase form. * The resulting blacklist array is used to filter out sensitive information. * @type {string[]} blacklist - An array of strings to be converted to lowercase and used as a blacklist. * @returns {string[]} - An array of lowercase strings representing the blacklist. */ const blacklist = ['password', 'token', 'accounts', 'authorization', 'key'].map(s => s.toLowerCase() ) /** * Configuration options for the logger. * @typedef {Object} LoggerConfig * @property {boolean | Array} [sensitiveFilteringKeywords] - Specifies whether to filter sensitive keywords in log messages. Can be a boolean value or an array of strings. * @property {LOG_LEVELS | string} [logLevel] - The log level to use for logging. Can be one of the predefined log levels or a custom string value. */ export type LoggerConfig = { sensitiveFilteringKeywords?: boolean | Array logLevel?: LOG_LEVELS | string /** When true, all log output is suppressed (e.g. health check routes). */ silent?: boolean } type SupressableItem = { value: any parent: any key: string | number } /** * Logger class for logging messages with different log levels. */ export default class Logger { /** * The optional configuration object for the logger. */ private config?: LoggerConfig /** * Private property representing the transaction ID. * @type {string} * @private */ private transactionID: string /** * An array of strings representing a blacklist of filters */ private filterBlacklist: string[] /** * The current log level for the application. * @private * @type {LOG_LEVELS} */ private _LOG_LEVEL: LOG_LEVELS /** * The origin of the object. * @private * @type {any} */ private origin: any /** * Constructs a Logger object with the given configuration and transaction ID. * @param {config} config - The configuration object for the logger. Can be undefined. * @param {string} transactionID - The ID of the transaction associated with the logger. * @returns None */ constructor(config: LoggerConfig | undefined, transactionID: string) { abind(this) // this.origin = PURE_CONSOLE this._LOG_LEVEL = config?.logLevel ? LOG_LEVELS[config?.logLevel] || LOG_LEVELS.DEBUG : LOG_LEVELS.DEBUG this.config = config || {} this.transactionID = transactionID this.filterBlacklist = this.config.sensitiveFilteringKeywords ? Array.isArray(this.config.sensitiveFilteringKeywords) ? this.config.sensitiveFilteringKeywords : blacklist : [] // this.setupBindings() // if (!this.config.silent) { this.log('Using logger with level: ' + this._LOG_LEVEL.toString()) this.debug('logger config: ', this.config) } } /** * Returns a boolean value indicating whether the notGlobalLogger function is executed successfully. * @returns {boolean} - true if the function is executed successfully, false otherwise. */ public notGlobalLogger() { return true } /** * Logs the given arguments with the debug log level. * @param {...any} args - The arguments to be logged. * @returns None */ public debug(...args) { this.processLog(LOG_LEVELS.DEBUG, args) } /** * Logs the given arguments with the INFO log level. * @param {...any} args - The arguments to be logged. * @returns None */ public log(...args) { this.processLog(LOG_LEVELS.INFO, args) } /** * Logs an informational message. * @param {...any} args - The message(s) to log. * @returns None */ public info(...args) { this.processLog(LOG_LEVELS.INFO, args) } /** * Logs a warning message with the provided arguments. * @param {...any} args - The arguments to be logged as a warning message. * @returns None */ public warning(...args) { this.processLog(LOG_LEVELS.WARN, args) } /** * Logs a warning message to the console. * @param {...any} args - The arguments to be logged. * @returns None */ public warn(...args) { this.processLog(LOG_LEVELS.WARN, args) } /** * Logs an error message with the given arguments. * @param {...any} args - The arguments to log as an error message. * @returns None */ public error(...args) { this.processLog(LOG_LEVELS.ERROR, args) } /** * Logs an exception with optional additional arguments. * @param {any} exception - The exception to log. * @param {...any} args - Additional arguments to include in the log. * @returns None */ public exception(exception: Error | unknown, ...args: any[]) { this.iexception(exception, args) } /** * Sets up the console bindings for logging purposes. * @private * @returns None */ private setupBindings(): void { global.console = { debug: (...args) => this.processLog(LOG_LEVELS.DEBUG, args), log: (...args) => this.processLog(LOG_LEVELS.INFO, args), info: (...args) => this.processLog(LOG_LEVELS.INFO, args), warn: (...args) => this.processLog(LOG_LEVELS.WARN, args), error: (...args) => this.processLog(LOG_LEVELS.ERROR, args), // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore warning: (...args) => this.processLog(LOG_LEVELS.WARN, args), exception: (exception, ...args) => this.iexception(exception, args), } } /** * Formats a log message with the specified log level, message, and caller. * @param {LOG_LEVELS} level - The log level of the message. * @param {Array} msg - An array of strings representing the message. * @param {string} caller - The name of the caller function. * @returns {string} - The formatted log message. */ private formattedLog(level: LOG_LEVELS, msg: Array, caller: string): string { if (Utils.isHybridlessContainer() && this.transactionID) { return `${this.transactionID}` + ` [${level.toString()}] [${caller}] ${msg.join(' ')}` } else { return `[${level.toString()}] [${caller}] ${msg.join(' ')}` } } /** * Retrieves the name of the caller function at the specified index in the stack trace. * @param {number} index - The index of the caller function in the stack trace. * @returns {string} The name of the caller function along with the file path and line number. */ private callerName(index: number): string { const safeIndex = Math.min(index, stackTrace.get().length) if (stackTrace.get()[safeIndex]) { let callerName = stackTrace?.get()?.[safeIndex]?.getFileName()?.split('/') callerName = callerName?.slice(callerName?.indexOf('src'))?.join('/') return callerName + ':' + stackTrace?.get()?.[safeIndex]?.getLineNumber() } return '' } /** * Processes log messages based on the specified log level. * @param {LOG_LEVELS} level - The level of the log message. * @param {any} args - The arguments to be logged. * @returns None */ private processLog(level: LOG_LEVELS, args: any): void { if (this.config?.silent) return if (LOG_LEVEL_RANK[level] < LOG_LEVEL_RANK[this._LOG_LEVEL]) return //get args const msg: string[] = [] for (const arg of args) { // Deep clone object so we dont modify source const fMsg = this.formatArgument(arg) msg.push(fMsg) } //push into logs stack // todo: improve error stack this.pushLog(level, this.formattedLog(level, msg, this.callerName(3))) } private formatArgument(arg: any): string { if (arg instanceof Error) { return arg.message + '\n' + arg.stack } if (arg && typeof arg === 'object') { return stringify(this.suppressSensitiveInfo(JSON.parse(stringify(arg))), null, 2) } return `${this.suppressSensitiveInfo(arg)}` } /** * Logs an exception along with additional arguments and the stack trace. * @param {Error} exception - The exception object to log. * @param {...any} args - Additional arguments to include in the log. * @returns None */ private iexception(exception: Error | unknown, ...args: any[]): void { //format message const msg: Array = [] //push exeception if (exception instanceof Error) { msg.push(exception.toString() + ' -') //get args for (const arg of args) if (arg != exception) msg.push(arg) if (exception.stack) msg.push(exception.stack) //push Exeception stack at the end } else { msg.push(JSON.stringify(exception)) //get args for (const arg of args) if (arg != exception) msg.push(arg) } //push into logs stack this.pushLog(LOG_LEVELS.ERROR, this.formattedLog(LOG_LEVELS.ERROR, msg, this.callerName(3))) } /** * Pushes a log message to the console with the specified log level. * @param {LOG_LEVELS} level - The log level of the message. * @param {string} fMsg - The formatted log message. * @returns None */ private pushLog(level: LOG_LEVELS, fMsg: string): void { DEFAULT_LOG_FUNCTION.apply(PURE_CONSOLE, [fMsg]) } /** * Suppresses sensitive information in the given value based on the filter blacklist. * @param {any} value - The value to suppress sensitive information from. * @returns {string} - The value with sensitive information suppressed. */ private suppressSensitiveInfo(value: any): string | any[] { if (!value || !this.filterBlacklist.length) return value const parent = [value] const stack: SupressableItem[] = [{ value, parent, key: 0 }] while (stack.length > 0) { this.suppressSensitiveInfoItem(stack.pop()!, stack.push.bind(stack)) } return parent[0] } private suppressSensitiveInfoItem( { value, parent, key }: SupressableItem, push: (e: SupressableItem) => void ) { if (!value) return if (typeof value === 'string') { this.suppressSensitiveString({ value, parent, key }, push) } else if (Array.isArray(value)) { value.forEach((v, index) => push({ value: v, parent: value, key: index })) } else if (typeof value === 'object') { this.suppressSensitiveObject(value, push) } } private suppressSensitiveString( { value, parent, key }: SupressableItem, push: (e: SupressableItem) => void ) { let modifiedValue = value try { // Try to parse json string modifiedValue = JSON.parse(value) push({ value: modifiedValue, parent, key }) } catch { const lower = value.toLowerCase() if (this.filterBlacklist.some(f => lower == f)) modifiedValue = `**SUPPRESSED_SENSITIVE_DATA** (${String(modifiedValue)?.length || 0} len)` } parent[key] = modifiedValue } private suppressSensitiveObject(value: object, push: (e: SupressableItem) => void) { Object.entries(value).forEach(([k, v]) => { const lower = k.toLowerCase() if (!v || !this.filterBlacklist.some(term => lower.includes(term))) { push({ value: v, parent: value, key: k }) return } const matchedTerm = this.filterBlacklist.find(term => lower.includes(term)) switch (matchedTerm) { case 'password': value[k] = '[MASKED]' break case 'authorization': value[k] = `Bearer [HASHED: ${Utils.hashValue(v)}]` break case 'token': case 'key': ;(value as any)[k] = `[HASHED: ${Utils.hashValue(v)}]` break default: value[k] = `**SUPPRESSED_SENSITIVE_DATA** (${String(v).length} len)` } }) } }