UNPKG

@villedemontreal/logger

Version:
557 lines (506 loc) 18.5 kB
import { LogLevel, utils } from '@villedemontreal/general-utils'; import * as fs from 'fs'; import * as http from 'http'; import * as _ from 'lodash'; import * as path from 'path'; import { DestinationStream, StreamEntry, pino } from 'pino'; import { PinoPretty } from 'pino-pretty'; import { LoggerConfigs } from './config/configs'; import { constants } from './config/constants'; const createRotatingFileStream = require('rotating-file-stream').createStream; // ========================================== // We export the LogLevel // ========================================== export { LogLevel } from '@villedemontreal/general-utils'; // ========================================== // This allows us to get the *TypeScript* // informations instead of the ones from the // transpiled Javascript file. // ========================================== require('source-map-support').install({ environment: 'node', }); // ========================================== // App infos // ========================================== const packageJson = require(`${constants.appRoot}/package.json`); const appName = packageJson.name; const appVersion = packageJson.version; let loggerInstance: pino.Logger; let loggerConfigs: LoggerConfigs; let libIsInited = false; // Keeping track of all created loggers const loggerChildren: Logger[] = []; let multistream: any; /** * A Logger. */ export interface ILogger { debug(messageObj: any, txtMsg?: string): void; info(messageObj: any, txtMsg?: string): void; warning(messageObj: any, txtMsg?: string): void; error(messageObj: any, txtMsg?: string): void; log(level: LogLevel, messageObj: any, txtMsg?: string): void; } /** * Converts a Pino level to its number value. */ export const convertPinoLevelToNumber = (pinoLogLevel: pino.Level): number => { return pino.levels.values[pinoLogLevel]; }; /** * Converts a local LogLevel to a Pino label level. */ export const convertLogLevelToPinoLabelLevel = (logLevel: LogLevel): pino.Level => { let pinoLevel: pino.Level = 'error'; if (logLevel !== undefined) { if (logLevel === LogLevel.TRACE) { pinoLevel = 'trace'; } else if (logLevel === LogLevel.DEBUG) { pinoLevel = 'debug'; } else if (logLevel === LogLevel.INFO) { pinoLevel = 'info'; } else if (logLevel === LogLevel.WARNING) { pinoLevel = 'warn'; } else if (logLevel === LogLevel.ERROR) { pinoLevel = 'error'; } } return pinoLevel; }; /** * Converts a local LogLevel to a Pino number level. */ export const convertLogLevelToPinoNumberLevel = (logLevel: LogLevel): number => { return convertPinoLevelToNumber(convertLogLevelToPinoLabelLevel(logLevel)); }; /** * Gets the path to the directory where to log, if required */ const getLogDirPath = (loggerConfig: LoggerConfigs): string => { let logDir: string = loggerConfig.getLogDirectory(); if (!path.isAbsolute(logDir)) { logDir = path.join(process.cwd(), logDir); } logDir = path.normalize(logDir); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir); } return logDir; }; /** * Initialize the logger with the config given in parameter * This function must be used before using createLogger or Logger Class * @param {LoggerConfigs} loggerConfig * @param {string} [name='default'] * @param force if `true`, the logger will be initialized * again even if it already is. */ export const initLogger = (loggerConfig: LoggerConfigs, name = 'default', force = false) => { if (loggerInstance && !force) { return; } const streams: (DestinationStream | StreamEntry)[] = []; loggerConfigs = loggerConfig; // ========================================== // Logs to stdout, potentially in a human friendly // format... // ========================================== if (loggerConfig.isLogHumanReadableinConsole()) { streams.push({ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()), stream: PinoPretty(), }); } else { streams.push({ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()), stream: process.stdout, }); } // ========================================== // Logs in a file too? // ========================================== if (loggerConfig.isLogToFile()) { const rotatingFilesStream = createRotatingFileStream('application.log', { path: getLogDirPath(loggerConfig), size: `${loggerConfig.getLogRotateThresholdMB()}M`, maxSize: `${loggerConfig.getLogRotateMaxTotalSizeMB()}M`, maxFiles: loggerConfig.getLogRotateFilesNbr(), }); // ========================================== // TODO // Temp console logs, to help debug this issue: // https://github.com/iccicci/rotating-file-stream/issues/17#issuecomment-384423230 // ========================================== rotatingFilesStream.on('error', (err: any) => { // tslint:disable-next-line:no-console console.log('Rotating File Stream error: ', err); }); rotatingFilesStream.on('warning', (err: any) => { // tslint:disable-next-line:no-console console.log('Rotating File Stream warning: ', err); }); streams.push({ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()), stream: rotatingFilesStream, }); } multistream = pino.multistream(streams); loggerInstance = pino( { name, safe: true, timestamp: pino.stdTimeFunctions.isoTime, // ISO-8601 timestamps messageKey: 'msg', level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()), }, multistream, ); libIsInited = true; }; /** * Change the global log level of the application. Useful to change dynamically * the log level of something that is already started. * @param level The log level to set for the application */ export const setGlobalLogLevel = (level: LogLevel) => { if (!loggerInstance) { throw new Error( 'You must use "initLogger" function in @villedemontreal/logger package before making new instance of Logger.', ); } // Change the log level and update children accordingly loggerInstance.level = convertLogLevelToPinoLabelLevel(level); for (const logger of loggerChildren) { logger.update(); } // ========================================== // The streams's levels need to be modified too. // ========================================== if (multistream && multistream.streams) { for (const stream of multistream.streams) { // We need to use the *numerical* level value here stream.level = convertLogLevelToPinoNumberLevel(level); } } }; /** * Shorthands function that return a new logger instance * Internally, we use the same logger instance but with different context like the name given in parameter * and this context is kept in this new instance returned. * @export * @param {string} name * @returns {ILogger} */ export function createLogger(name: string): ILogger { return new Logger(name); } export function isInited(): boolean { return libIsInited; } /** * Logger implementation. */ export class Logger implements ILogger { private readonly pino: pino.Logger; /** * Creates a logger. * * @param the logger name. This name should be related * to the file the logger is created in. On a production * environment, it's possible that only this name will * be available to locate the source of the log. * Streams will be created after the first call to the logger */ constructor(name: string) { if (!loggerInstance) { throw new Error( 'You must use "initLogger" function in @villedemontreal/logger package before making new instance of Logger.', ); } this.pino = loggerInstance.child({ name }); loggerChildren.push(this); } /** * Logs a DEBUG level message object. * * If the extra "txtMsg" parameter is set, it is * going to be added to messageObj as a ".msg" * property (if messageObj is an object) or * concatenated to messageObj (if it's not an * object). * * Those types of logs are possible : * * - log.debug("a simple text message"); * - log.debug({"name": "an object"}); * - log.debug({"name": "an object..."}, "... and an extra text message"); * - log.debug(err, "a catched error and an explanation message"); */ public debug(messageObj: any, txtMsg?: string) { this.log(LogLevel.DEBUG, messageObj, txtMsg); } /** * Logs an INFO level message. * * If the extra "txtMsg" parameter is set, it is * going to be added to messageObj as a ".msg" * property (if messageObj is an object) or * concatenated to messageObj (if it's not an * object). * * Those types of logs are possible : * * - log.info("a simple text message"); * - log.info({"name": "an object"}); * - log.info({"name": "an object..."}, "... and an extra text message"); * - log.info(err, "a catched error and an explanation message");public */ public info(messageObj: any, txtMsg?: string) { this.log(LogLevel.INFO, messageObj, txtMsg); } /** * Logs a WARNING level message. * * If the extra "txtMsg" parameter is set, it is * going to be added to messageObj as a ".msg" * property (if messageObj is an object) or * concatenated to messageObj (if it's not an * object). * * Those types of logs are possible : * * - log.warning("a simple text message"); * - log.warning({"name": "an object"}); * - log.warning({"name": "an object..."}, "... and an extra text message"); * - log.warning(err, "a catched error and an explanation mespublic sage"); */ public warning(messageObj: any, txtMsg?: string) { this.log(LogLevel.WARNING, messageObj, txtMsg); } /** * Logs an ERROR level message. * * If the extra "txtMsg" parameter is set, it is * going to be added to messageObj as a ".msg" * property (if messageObj is an object) or * concatenated to messageObj (if it's not an * object). * * Those types of logs are possible : * * - log.error("a simple text message"); * - log.error({"name": "an object"}); * - log.error({"name": "an object..."}, "... and an extra text message"); * - log.error(err, "a catched error and an explanatpublic ion message"); */ public error(messageObj: any, txtMsg?: string) { this.log(LogLevel.ERROR, messageObj, txtMsg); } /** * Logs a level specific message. * * If the extra "txtMsg" parameter is set, it is * going to be added to messageObj as a ".msg" * property (if messageObj is an object) or * concatenated to messageObj (if it's not an * object). * * Those types of logs are possible : * * - log(LogLevel.XXXXX, "a simple text message"); * - log({"name": "an object"}); * - log({"name": "an object..."}, "... and an extra text message"); * - log(err, "a catched error and an epublic xplanation message"); */ // tslint:disable-next-line:cyclomatic-complexity public log(level: LogLevel, messageObj: any, txtMsg?: string) { let messageObjClean = messageObj; const txtMsgClean = txtMsg; if (messageObjClean === null || messageObjClean === undefined) { messageObjClean = {}; } else if (_.isArray(messageObjClean)) { try { loggerInstance.error( `The message object to log can't be an array. An object will be used instead and` + `the content of the array will be moved to an "_arrayMsg" property on it : ${messageObjClean}`, ); } catch (err) { // too bad } messageObjClean = { _arrayMsg: _.cloneDeep(messageObjClean), }; } if (utils.isObjectStrict(messageObjClean)) { // ========================================== // The underlying logger may ignore all fields // except "message" if // the message object is an instance of the // native "Error" class. But we may want to use // that Error class to log more fields. For example : // // let error: any = new Error("my message"); // error.customKey1 = "value1"; // error.customKey2 = "value2"; // throw error; // // This is useful if we need a *stackTrace*, which // the Error class allows. // // This is why we create a plain object from that Error // object. // ========================================== if (messageObjClean instanceof Error) { const messageObjNew: any = {}; messageObjNew.name = messageObj.name; messageObjNew.msg = messageObj.message; messageObjNew.stack = messageObj.stack; // Some extra custom properties? messageObjClean = _.assignIn(messageObjNew, messageObj); } else if (messageObjClean instanceof http.IncomingMessage && messageObjClean.socket) { // ========================================== // This is a weird case! // When logging an Express Request, Pino transforms // it first: https://github.com/pinojs/pino-std-serializers/blob/master/lib/req.js#L65 // But doing so it accesses the `connection.remoteAddress` prpperty // and, in some contexts, the simple fact to access this property // throws an error: // "TypeError: Illegal invocation\n at Socket._getpeername (net.js:712:30)" // // The workaround is to access this property in a try/catch // and, if an error occures, force its value to // a simple string. // ========================================== messageObjClean = _.cloneDeep(messageObjClean); try { // tslint:disable-next-line:no-unused-expression messageObjClean.socket.remoteAddress; } catch (err) { messageObjClean.socket = { ...messageObjClean.socket, remoteAddress: '[not available]', }; } } else { messageObjClean = _.cloneDeep(messageObjClean); } // ========================================== // Pino will always use the "msg" preoperty of // the object if it exists, even if we pass a // second parameter consisting in the message. // ========================================== if (txtMsgClean) { messageObjClean.msg = (messageObjClean.msg ? `${messageObjClean.msg} - ` : '') + txtMsgClean; } } else { const suffix = txtMsgClean ? ` - ${txtMsgClean}` : ''; messageObjClean = { msg: `${messageObjClean}${suffix}`, }; } if (level === LogLevel.TRACE) { this.pino.trace(this.enhanceLog(messageObjClean)); } else if (level === LogLevel.DEBUG) { this.pino.debug(this.enhanceLog(messageObjClean)); } else if (level === LogLevel.INFO) { this.pino.info(this.enhanceLog(messageObjClean)); } else if (level === LogLevel.WARNING) { this.pino.warn(this.enhanceLog(messageObjClean)); } else if (level === LogLevel.ERROR) { this.pino.error(this.enhanceLog(messageObjClean)); } else { try { loggerInstance.error(`UNMANAGED LEVEL "${level}"`); } catch (err) { // too bad } this.pino.error(this.enhanceLog(messageObjClean)); } } /** * Update the logger based on the parent changes. * Could use something more precise to handle specific event but * people could use it to update the child independently from the parent, * which is not what is intended. */ public update() { // Set new level this.pino.level = loggerInstance.level; } /** * Adds the file and line number where the log occures. * This particular code is required since our custom Logger * is a layer over Pino and therefore adds an extra level * to the error stack. Without this code, the file and line number * are not the right ones. * * Based by http://stackoverflow.com/a/38197778/843699 */ private enhanceLog(messageObj: any) { // ========================================== // Adds a property to indicate this is a // Montreal type of log entry. // // TODO validate this + adds standardized // properties. // ========================================== if (!(constants.logging.properties.LOG_TYPE in messageObj)) { messageObj[constants.logging.properties.LOG_TYPE] = constants.logging.logType.MONTREAL; // ========================================== // TO UPDATE when the properties added to the // log change! // // 1 : first version with Bunyan // 2 : With Pino // ========================================== messageObj[constants.logging.properties.LOG_TYPE_VERSION] = '2'; } // ========================================== // cid : correlation id // ========================================== const cid = loggerConfigs.correlationId; if (cid) { messageObj[constants.logging.properties.CORRELATION_ID] = cid; } // ========================================== // "app" and "version" // @see https://sticonfluence.interne.montreal.ca/pages/viewpage.action?pageId=43530740 // ========================================== messageObj[constants.logging.properties.APP_NAME] = appName; messageObj[constants.logging.properties.APP_VERSION] = appVersion; if (!loggerConfigs.isLogSource()) { return messageObj; } let stackLine; const stackLines = new Error().stack.split('\n'); stackLines.shift(); for (const stackLineTry of stackLines) { if (stackLineTry.indexOf(`at ${(Logger as any).name}.`) <= 0) { stackLine = stackLineTry; break; } } if (!stackLine) { return messageObj; } let callerLine = ''; if (stackLine.indexOf(')') >= 0) { callerLine = stackLine.slice(stackLine.lastIndexOf('/'), stackLine.lastIndexOf(')')); if (callerLine.length === 0) { callerLine = stackLine.slice(stackLine.lastIndexOf('('), stackLine.lastIndexOf(')')); } } else { callerLine = stackLine.slice(stackLine.lastIndexOf('at ') + 2); } const firstCommaPos = callerLine.lastIndexOf(':', callerLine.lastIndexOf(':') - 1); const filename = callerLine.slice(1, firstCommaPos); const lineNo = callerLine.slice(firstCommaPos + 1, callerLine.indexOf(':', firstCommaPos + 1)); messageObj.src = { file: filename, line: lineNo, }; return messageObj; } }