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