@villedemontreal/logger 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/logger.ts ADDED
@@ -0,0 +1,551 @@
1
+ import { LogLevel, utils } from '@villedemontreal/general-utils';
2
+ import * as fs from 'fs';
3
+ import * as http from 'http';
4
+ import * as _ from 'lodash';
5
+ import * as path from 'path';
6
+ import * as pino from 'pino';
7
+ import * as pinoMultiStreams from 'pino-multi-stream';
8
+ import { LoggerConfigs } from './config/configs';
9
+ import { constants } from './config/constants';
10
+
11
+ const createRotatingFileStream = require('rotating-file-stream').createStream;
12
+
13
+ // ==========================================
14
+ // We export the LogLevel
15
+ // ==========================================
16
+ export { LogLevel } from '@villedemontreal/general-utils';
17
+
18
+ // ==========================================
19
+ // This allows us to get the *TypeScript*
20
+ // informations instead of the ones from the
21
+ // transpiled Javascript file.
22
+ // ==========================================
23
+ require('source-map-support').install({
24
+ environment: 'node'
25
+ });
26
+
27
+ // ==========================================
28
+ // App infos
29
+ // ==========================================
30
+ const packageJson = require(`${constants.appRoot}/package.json`);
31
+ const appName = packageJson.name;
32
+ const appVersion = packageJson.version;
33
+
34
+ let loggerInstance: pino.Logger;
35
+ let loggerConfigs: LoggerConfigs;
36
+ let libIsInited: boolean = false;
37
+
38
+ // Keeping track of all created loggers
39
+ const loggerChildren: Logger[] = [];
40
+
41
+ let multistream: any;
42
+
43
+ /**
44
+ * A Logger.
45
+ */
46
+ export interface ILogger {
47
+ debug(messageObj: any, txtMsg?: string): void;
48
+ info(messageObj: any, txtMsg?: string): void;
49
+ warning(messageObj: any, txtMsg?: string): void;
50
+ error(messageObj: any, txtMsg?: string): void;
51
+ log(level: LogLevel, messageObj: any, txtMsg?: string): void;
52
+ }
53
+
54
+ /**
55
+ * Converts a Pino level to its number value.
56
+ */
57
+ export const convertPinoLevelToNumber = (pinoLogLevel: pino.Level): number => {
58
+ return pino.levels.values[pinoLogLevel];
59
+ };
60
+
61
+ /**
62
+ * Converts a local LogLevel to a Pino label level.
63
+ */
64
+ export const convertLogLevelToPinoLabelLevel = (logLevel: LogLevel): pino.Level => {
65
+ let pinoLevel: pino.Level = 'error';
66
+ if (logLevel !== undefined) {
67
+ if ((logLevel as LogLevel) === LogLevel.DEBUG) {
68
+ pinoLevel = 'debug';
69
+ } else if (logLevel === LogLevel.INFO) {
70
+ pinoLevel = 'info';
71
+ } else if (logLevel === LogLevel.WARNING) {
72
+ pinoLevel = 'warn';
73
+ } else if (logLevel === LogLevel.ERROR) {
74
+ pinoLevel = 'error';
75
+ }
76
+ }
77
+ return pinoLevel;
78
+ };
79
+
80
+ /**
81
+ * Converts a local LogLevel to a Pino number level.
82
+ */
83
+ export const convertLogLevelToPinoNumberLevel = (logLevel: LogLevel): number => {
84
+ return convertPinoLevelToNumber(convertLogLevelToPinoLabelLevel(logLevel));
85
+ };
86
+
87
+ /**
88
+ * Gets the path to the directory where to log, if required
89
+ */
90
+ const getLogDirPath = (loggerConfig: LoggerConfigs): string => {
91
+ let logDir: string = loggerConfig.getLogDirectory();
92
+
93
+ if (!path.isAbsolute(logDir)) {
94
+ logDir = path.join(process.cwd(), logDir);
95
+ }
96
+ logDir = path.normalize(logDir);
97
+
98
+ if (!fs.existsSync(logDir)) {
99
+ fs.mkdirSync(logDir);
100
+ }
101
+
102
+ return logDir;
103
+ };
104
+
105
+ /**
106
+ * Initialize the logger with the config given in parameter
107
+ * This function must be used before using createLogger or Logger Class
108
+ * @param {LoggerConfigs} loggerConfig
109
+ * @param {string} [name='default']
110
+ * @param force if `true`, the logger will be initialized
111
+ * again even if it already is.
112
+ */
113
+ export const initLogger = (loggerConfig: LoggerConfigs, name = 'default', force: boolean = false) => {
114
+ if (loggerInstance && !force) {
115
+ return;
116
+ }
117
+
118
+ const streams: pinoMultiStreams.Streams = [];
119
+ loggerConfigs = loggerConfig;
120
+ // ==========================================
121
+ // Logs to stdout, potentially in a human friendly
122
+ // format...
123
+ // ==========================================
124
+ if (loggerConfig.isLogHumanReadableinConsole()) {
125
+ const prettyStream = pinoMultiStreams.prettyStream();
126
+ streams.push({
127
+ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()),
128
+ stream: prettyStream
129
+ });
130
+ } else {
131
+ streams.push({
132
+ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()),
133
+ stream: process.stdout
134
+ });
135
+ }
136
+
137
+ // ==========================================
138
+ // Logs in a file too?
139
+ // ==========================================
140
+ if (loggerConfig.isLogToFile()) {
141
+ const rotatingFilesStream = createRotatingFileStream('application.log', {
142
+ path: getLogDirPath(loggerConfig),
143
+ size: loggerConfig.getLogRotateThresholdMB() + 'M',
144
+ maxSize: loggerConfig.getLogRotateMaxTotalSizeMB() + 'M',
145
+ maxFiles: loggerConfig.getLogRotateFilesNbr()
146
+ });
147
+
148
+ // ==========================================
149
+ // TODO
150
+ // Temp console logs, to help debug this issue:
151
+ // https://github.com/iccicci/rotating-file-stream/issues/17#issuecomment-384423230
152
+ // ==========================================
153
+ rotatingFilesStream.on('error', (err: any) => {
154
+ // tslint:disable-next-line:no-console
155
+ console.log('Rotating File Stream error: ', err);
156
+ });
157
+ rotatingFilesStream.on('warning', (err: any) => {
158
+ // tslint:disable-next-line:no-console
159
+ console.log('Rotating File Stream warning: ', err);
160
+ });
161
+
162
+ streams.push({
163
+ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel()),
164
+ stream: rotatingFilesStream
165
+ });
166
+ }
167
+
168
+ multistream = pinoMultiStreams.multistream(streams);
169
+ loggerInstance = pino(
170
+ {
171
+ name,
172
+ safe: true,
173
+ timestamp: pino.stdTimeFunctions.isoTime, // ISO-8601 timestamps
174
+ messageKey: 'msg',
175
+ level: convertLogLevelToPinoLabelLevel(loggerConfig.getLogLevel())
176
+ },
177
+ multistream
178
+ );
179
+
180
+ libIsInited = true;
181
+ };
182
+
183
+ /**
184
+ * Change the global log level of the application. Useful to change dynamically
185
+ * the log level of something that is already started.
186
+ * @param level The log level to set for the application
187
+ */
188
+ export const setGlobalLogLevel = (level: LogLevel) => {
189
+ if (!loggerInstance) {
190
+ throw new Error(
191
+ 'You must use "initLogger" function in @villemontreal/core-utils-logger-nodejs-lib package before making new instance of Logger class.'
192
+ );
193
+ }
194
+ // Change the log level and update children accordingly
195
+ loggerInstance.level = convertLogLevelToPinoLabelLevel(level);
196
+ for (const logger of loggerChildren) {
197
+ logger.update();
198
+ }
199
+
200
+ // ==========================================
201
+ // The streams's levels need to be modified too.
202
+ // ==========================================
203
+ if (multistream && multistream.streams) {
204
+ for (const stream of multistream.streams) {
205
+ // We need to use the *numerical* level value here
206
+ stream.level = convertLogLevelToPinoNumberLevel(level);
207
+ }
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Shorthands function that return a new logger instance
213
+ * Internally, we use the same logger instance but with different context like the name given in parameter
214
+ * and this context is kept in this new instance returned.
215
+ * @export
216
+ * @param {string} name
217
+ * @returns {ILogger}
218
+ */
219
+ export function createLogger(name: string): ILogger {
220
+ return new Logger(name);
221
+ }
222
+
223
+ export function isInited(): boolean {
224
+ return libIsInited;
225
+ }
226
+
227
+ /**
228
+ * Logger implementation.
229
+ */
230
+ export class Logger implements ILogger {
231
+ private readonly pino: pino.Logger;
232
+
233
+ /**
234
+ * Creates a logger.
235
+ *
236
+ * @param the logger name. This name should be related
237
+ * to the file the logger is created in. On a production
238
+ * environment, it's possible that only this name will
239
+ * be available to locate the source of the log.
240
+ * Streams will be created after the first call to the logger
241
+ */
242
+ constructor(name: string) {
243
+ if (!loggerInstance) {
244
+ throw new Error(
245
+ 'You must use "initLogger" function in @villemontreal/core-utils-logger-nodejs-lib package before making new instance of Logger class.'
246
+ );
247
+ }
248
+ this.pino = loggerInstance.child({ name });
249
+ loggerChildren.push(this);
250
+ }
251
+
252
+ /**
253
+ * Logs a DEBUG level message object.
254
+ *
255
+ * If the extra "txtMsg" parameter is set, it is
256
+ * going to be added to messageObj as a ".msg"
257
+ * property (if messageObj is an object) or
258
+ * concatenated to messageObj (if it's not an
259
+ * object).
260
+ *
261
+ * Those types of logs are possible :
262
+ *
263
+ * - log.debug("a simple text message");
264
+ * - log.debug({"name": "an object"});
265
+ * - log.debug({"name": "an object..."}, "... and an extra text message");
266
+ * - log.debug(err, "a catched error and an explanation message");
267
+ */
268
+ public debug(messageObj: any, txtMsg?: string) {
269
+ this.log(LogLevel.DEBUG, messageObj, txtMsg);
270
+ }
271
+
272
+ /**
273
+ * Logs an INFO level message.
274
+ *
275
+ * If the extra "txtMsg" parameter is set, it is
276
+ * going to be added to messageObj as a ".msg"
277
+ * property (if messageObj is an object) or
278
+ * concatenated to messageObj (if it's not an
279
+ * object).
280
+ *
281
+ * Those types of logs are possible :
282
+ *
283
+ * - log.info("a simple text message");
284
+ * - log.info({"name": "an object"});
285
+ * - log.info({"name": "an object..."}, "... and an extra text message");
286
+ * - log.info(err, "a catched error and an explanation message");public
287
+ */
288
+ public info(messageObj: any, txtMsg?: string) {
289
+ this.log(LogLevel.INFO, messageObj, txtMsg);
290
+ }
291
+
292
+ /**
293
+ * Logs a WARNING level message.
294
+ *
295
+ * If the extra "txtMsg" parameter is set, it is
296
+ * going to be added to messageObj as a ".msg"
297
+ * property (if messageObj is an object) or
298
+ * concatenated to messageObj (if it's not an
299
+ * object).
300
+ *
301
+ * Those types of logs are possible :
302
+ *
303
+ * - log.warning("a simple text message");
304
+ * - log.warning({"name": "an object"});
305
+ * - log.warning({"name": "an object..."}, "... and an extra text message");
306
+ * - log.warning(err, "a catched error and an explanation mespublic sage");
307
+ */
308
+ public warning(messageObj: any, txtMsg?: string) {
309
+ this.log(LogLevel.WARNING, messageObj, txtMsg);
310
+ }
311
+
312
+ /**
313
+ * Logs an ERROR level message.
314
+ *
315
+ * If the extra "txtMsg" parameter is set, it is
316
+ * going to be added to messageObj as a ".msg"
317
+ * property (if messageObj is an object) or
318
+ * concatenated to messageObj (if it's not an
319
+ * object).
320
+ *
321
+ * Those types of logs are possible :
322
+ *
323
+ * - log.error("a simple text message");
324
+ * - log.error({"name": "an object"});
325
+ * - log.error({"name": "an object..."}, "... and an extra text message");
326
+ * - log.error(err, "a catched error and an explanatpublic ion message");
327
+ */
328
+ public error(messageObj: any, txtMsg?: string) {
329
+ this.log(LogLevel.ERROR, messageObj, txtMsg);
330
+ }
331
+
332
+ /**
333
+ * Logs a level specific message.
334
+ *
335
+ * If the extra "txtMsg" parameter is set, it is
336
+ * going to be added to messageObj as a ".msg"
337
+ * property (if messageObj is an object) or
338
+ * concatenated to messageObj (if it's not an
339
+ * object).
340
+ *
341
+ * Those types of logs are possible :
342
+ *
343
+ * - log(LogLevel.XXXXX, "a simple text message");
344
+ * - log({"name": "an object"});
345
+ * - log({"name": "an object..."}, "... and an extra text message");
346
+ * - log(err, "a catched error and an epublic xplanation message");
347
+ */
348
+ // tslint:disable-next-line:cyclomatic-complexity
349
+ public log(level: LogLevel, messageObj: any, txtMsg?: string) {
350
+ let messageObjClean = messageObj;
351
+ const txtMsgClean = txtMsg;
352
+
353
+ if (messageObjClean === null || messageObjClean === undefined) {
354
+ messageObjClean = {};
355
+ } else if (_.isArray(messageObjClean)) {
356
+ try {
357
+ loggerInstance.error(
358
+ `The message object to log can't be an array. An object will be used instead and` +
359
+ `the content of the array will be moved to an "_arrayMsg" property on it : ${messageObjClean}`
360
+ );
361
+ } catch (err) {
362
+ // too bad
363
+ }
364
+ messageObjClean = {
365
+ _arrayMsg: _.cloneDeep(messageObjClean)
366
+ };
367
+ }
368
+
369
+ if (utils.isObjectStrict(messageObjClean)) {
370
+ // ==========================================
371
+ // The underlying logger may ignore all fields
372
+ // except "message" if
373
+ // the message object is an instance of the
374
+ // native "Error" class. But we may want to use
375
+ // that Error class to log more fields. For example :
376
+ //
377
+ // let error: any = new Error("my message");
378
+ // error.customKey1 = "value1";
379
+ // error.customKey2 = "value2";
380
+ // throw error;
381
+ //
382
+ // This is useful if we need a *stackTrace*, which
383
+ // the Error class allows.
384
+ //
385
+ // This is why we create a plain object from that Error
386
+ // object.
387
+ // ==========================================
388
+ if (messageObjClean instanceof Error) {
389
+ const messageObjNew: any = {};
390
+ messageObjNew.name = messageObj.name;
391
+ messageObjNew.msg = messageObj.message;
392
+ messageObjNew.stack = messageObj.stack;
393
+
394
+ // Some extra custom properties?
395
+ messageObjClean = _.assignIn(messageObjNew, messageObj);
396
+ } else if (messageObjClean instanceof http.IncomingMessage && messageObjClean.socket) {
397
+ // ==========================================
398
+ // This is a weird case!
399
+ // When logging an Express Request, Pino transforms
400
+ // it first: https://github.com/pinojs/pino-std-serializers/blob/master/lib/req.js#L65
401
+ // But doing so it accesses the `connection.remoteAddress` prpperty
402
+ // and, in some contexts, the simple fact to access this property
403
+ // throws an error:
404
+ // "TypeError: Illegal invocation\n at Socket._getpeername (net.js:712:30)"
405
+ //
406
+ // The workaround is to access this property in a try/catch
407
+ // and, if an error occures, force its value to
408
+ // a simple string.
409
+ // ==========================================
410
+ messageObjClean = _.cloneDeep(messageObjClean);
411
+ try {
412
+ // tslint:disable-next-line:no-unused-expression
413
+ messageObjClean.socket.remoteAddress;
414
+ } catch (err) {
415
+ messageObjClean.socket = {
416
+ ...messageObjClean.socket,
417
+ remoteAddress: '[not available]'
418
+ };
419
+ }
420
+ } else {
421
+ messageObjClean = _.cloneDeep(messageObjClean);
422
+ }
423
+
424
+ // ==========================================
425
+ // Pino will always use the "msg" preoperty of
426
+ // the object if it exists, even if we pass a
427
+ // second parameter consisting in the message.
428
+ // ==========================================
429
+ if (txtMsgClean) {
430
+ messageObjClean.msg = (messageObjClean.msg ? messageObjClean.msg + ' - ' : '') + txtMsgClean;
431
+ }
432
+ } else {
433
+ messageObjClean = {
434
+ msg: messageObjClean + (txtMsgClean ? ` - ${txtMsgClean}` : '')
435
+ };
436
+ }
437
+
438
+ if (level === LogLevel.DEBUG) {
439
+ this.pino.debug(this.enhanceLog(messageObjClean));
440
+ } else if (level === LogLevel.INFO) {
441
+ this.pino.info(this.enhanceLog(messageObjClean));
442
+ } else if (level === LogLevel.WARNING) {
443
+ this.pino.warn(this.enhanceLog(messageObjClean));
444
+ } else if (level === LogLevel.ERROR) {
445
+ this.pino.error(this.enhanceLog(messageObjClean));
446
+ } else {
447
+ try {
448
+ loggerInstance.error(`UNMANAGED LEVEL "${level}"`);
449
+ } catch (err) {
450
+ // too bad
451
+ }
452
+
453
+ this.pino.error(this.enhanceLog(messageObjClean));
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Update the logger based on the parent changes.
459
+ * Could use something more precise to handle specific event but
460
+ * people could use it to update the child independently from the parent,
461
+ * which is not what is intended.
462
+ */
463
+ public update() {
464
+ // Set new level
465
+ this.pino.level = loggerInstance.level;
466
+ }
467
+
468
+ /**
469
+ * Adds the file and line number where the log occures.
470
+ * This particular code is required since our custom Logger
471
+ * is a layer over Pino and therefore adds an extra level
472
+ * to the error stack. Without this code, the file and line number
473
+ * are not the right ones.
474
+ *
475
+ * Based by http://stackoverflow.com/a/38197778/843699
476
+ */
477
+ private enhanceLog(messageObj: any) {
478
+ // ==========================================
479
+ // Adds a property to indicate this is a
480
+ // Montreal type of log entry.
481
+ //
482
+ // TODO validate this + adds standardized
483
+ // properties.
484
+ // ==========================================
485
+ if (!(constants.logging.properties.LOG_TYPE in messageObj)) {
486
+ messageObj[constants.logging.properties.LOG_TYPE] = constants.logging.logType.MONTREAL;
487
+
488
+ // ==========================================
489
+ // TO UPDATE when the properties added to the
490
+ // log change!
491
+ //
492
+ // 1 : first version with Bunyan
493
+ // 2 : With Pino
494
+ // ==========================================
495
+ messageObj[constants.logging.properties.LOG_TYPE_VERSION] = '2';
496
+ }
497
+
498
+ // ==========================================
499
+ // cid : correlation id
500
+ // ==========================================
501
+ const cid = loggerConfigs.correlationId;
502
+ if (cid) {
503
+ messageObj[constants.logging.properties.CORRELATION_ID] = cid;
504
+ }
505
+
506
+ // ==========================================
507
+ // "app" and "version"
508
+ // @see https://sticonfluence.interne.montreal.ca/pages/viewpage.action?pageId=43530740
509
+ // ==========================================
510
+ messageObj[constants.logging.properties.APP_NAME] = appName;
511
+ messageObj[constants.logging.properties.APP_VERSION] = appVersion;
512
+
513
+ if (!loggerConfigs.isLogSource()) {
514
+ return messageObj;
515
+ }
516
+
517
+ let stackLine;
518
+ const stackLines = new Error().stack.split('\n');
519
+ stackLines.shift();
520
+ for (const stackLineTry of stackLines) {
521
+ if (stackLineTry.indexOf(`at ${(Logger as any).name}.`) <= 0) {
522
+ stackLine = stackLineTry;
523
+ break;
524
+ }
525
+ }
526
+ if (!stackLine) {
527
+ return messageObj;
528
+ }
529
+
530
+ let callerLine = '';
531
+ if (stackLine.indexOf(')') >= 0) {
532
+ callerLine = stackLine.slice(stackLine.lastIndexOf('/'), stackLine.lastIndexOf(')'));
533
+ if (callerLine.length === 0) {
534
+ callerLine = stackLine.slice(stackLine.lastIndexOf('('), stackLine.lastIndexOf(')'));
535
+ }
536
+ } else {
537
+ callerLine = stackLine.slice(stackLine.lastIndexOf('at ') + 2);
538
+ }
539
+
540
+ const firstCommaPos = callerLine.lastIndexOf(':', callerLine.lastIndexOf(':') - 1);
541
+ const filename = callerLine.slice(1, firstCommaPos);
542
+ const lineNo = callerLine.slice(firstCommaPos + 1, callerLine.indexOf(':', firstCommaPos + 1));
543
+
544
+ messageObj.src = {
545
+ file: filename,
546
+ line: lineNo
547
+ };
548
+
549
+ return messageObj;
550
+ }
551
+ }