bump package version to 3.0.3
[squeep-logger-json-console] / lib / logger.js
1 'use strict';
2
3 const jsonReplacers = require('./json-replacers');
4 const dataSanitizers = require('./data-sanitizers');
5
6 const nop = () => undefined;
7
8 class Logger {
9 /**
10 * @typedef {Object} ConsoleLike
11 * @property {Function(...):void} error
12 * @property {Function(...):void} warn
13 * @property {Function(...):void} info
14 * @property {Function(...):void} log
15 * @property {Function(...):void} debug
16 */
17 /**
18 * @typedef {Object} LoggerOptions
19 * @property {String} ignoreBelowLevel - minimum level to log, e.g. 'info'
20 */
21 /**
22 * Wrap backend calls with payload normalization and metadata.
23 * @param {LoggerOptions} options
24 * @param {Object} commonObject - object to be merged into logged json
25 * @param {AsyncLocalStorage} asyncLocalStorage - async storage for an object to be merged into logged json
26 * @param {ConsoleLike} backend - default is console
27 */
28 constructor(options, commonObject, asyncLocalStorage, backend) {
29 const logLevels = Object.keys(Logger.nullLogger);
30 const ignoreBelowLevel = options?.ignoreBelowLevel || 'debug';
31 this.backend = backend || console;
32 this.commonObject = commonObject || {};
33 this.jsonReplacers = Object.values(jsonReplacers);
34 this.dataSanitizers = Object.values(dataSanitizers);
35
36 if (asyncLocalStorage) {
37 // Override the class getter.
38 Object.defineProperty(this, 'asyncLogObject', {
39 enumerable: true,
40 get: asyncLocalStorage.getStore.bind(asyncLocalStorage),
41 });
42 }
43
44 if (!logLevels.includes(ignoreBelowLevel)) {
45 throw new RangeError(`unrecognized minimum log level '${ignoreBelowLevel}'`);
46 }
47 const ignoreLevelIdx = logLevels.indexOf(ignoreBelowLevel);
48 logLevels.forEach((level) => {
49 const includeBackendLevel = (logLevels.indexOf(level) > ignoreLevelIdx) && this.backend[level]; // eslint-disable-line security/detect-object-injection
50 // eslint-disable-next-line security/detect-object-injection
51 this[level] = (includeBackendLevel) ?
52 nop :
53 (...args) => this.backend[level](this.payload(level, ...args)); // eslint-disable-line security/detect-object-injection
54 });
55 }
56
57
58 /**
59 * All the expected Console levels, but do nothing.
60 * Ordered from highest priority to lowest.
61 */
62 static get nullLogger() {
63 return {
64 error: nop,
65 warn: nop,
66 info: nop,
67 log: nop,
68 debug: nop,
69 };
70 }
71
72
73 /**
74 * Default of empty object when no asyncLocalStorage is defined.
75 * Overridden on instance when asyncLocalStorage is defined.
76 */
77 // eslint-disable-next-line class-methods-use-this
78 get asyncLogObject() {
79 return {};
80 }
81
82
83 /**
84 * Structure all expected log data into JSON string.
85 * @param {String} level
86 * @param {String} scope
87 * @param {String} message
88 * @param {Object} data
89 * @param {...any} other
90 * @returns {String} JSON string
91 */
92 payload(level, scope, message, data, ...other) {
93 const replacer = this.getReplacer();
94
95 if (this.sanitizationNeeded(data)) {
96 // Create copy of data so we are not changing anything important.
97 try {
98 data = structuredClone(data);
99 } catch (e) {
100 data = JSON.parse(JSON.stringify(data, replacer));
101 }
102 this.sanitize(data);
103 }
104
105 const now = new Date();
106 const logPayload = {
107 ...this.commonObject,
108 timestamp: now.toISOString(),
109 timestampMs: now.getTime(),
110 level: level,
111 scope: scope || '[unknown]',
112 message: message || '',
113 data: data || {},
114 ...(other.length && { other }),
115 ...this.asyncLogObject,
116 };
117 return JSON.stringify(logPayload, replacer);
118 }
119
120
121 /**
122 * Determine if data needs sanitizing.
123 * @param {Object} data
124 * @returns {Boolean}
125 */
126 sanitizationNeeded(data) {
127 return this.dataSanitizers.some((sanitizer) => sanitizer(data, false));
128 }
129
130
131 /**
132 * Mogrify data.
133 * @param {Object} data
134 */
135 sanitize(data) {
136 this.dataSanitizers.forEach((sanitizer) => sanitizer(data));
137 }
138
139
140 /**
141 * Return a replacer function which does de-cycling, as well as the rest of our replacers.
142 */
143 getReplacer() {
144 const ancestors = [];
145 const loggerReplacers = this.jsonReplacers;
146 return function cycleReplacer(key, value) {
147 loggerReplacers.every((replacer) => {
148 const oldValue = value;
149 value = replacer(key, value);
150 return oldValue === value;
151 });
152 if (typeof value === 'object' && value !== null) {
153 // 'this' is object where key/value came from
154 while (ancestors.length > 0 && ancestors.at(-1) !== this) {
155 ancestors.pop();
156 }
157 if (ancestors.includes(value)) {
158 return '[Circular]';
159 } else {
160 ancestors.push(value);
161 }
162 }
163
164 return value;
165 };
166 }
167
168 }
169
170 module.exports = Logger;