ntlogger 2.9.1 → 3.0.0

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.
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @file /lib/pinoHooks.js
5
+ * @description Pino adapter that exposes NightTimeLogger's sampling, rate limiting and
6
+ * deduplication features (lib/logSampler.js, lib/logDeduplicator.js) through a
7
+ * Pino `hooks.logMethod` implementation.
8
+ *
9
+ * The Winston path applies these features inside the wrapped log method
10
+ * (see lib/logger.js -> wrapLoggerMethod). Pino has no such wrapper, but it does expose
11
+ * `hooks.logMethod(inputArgs, method, level)` which is invoked *before* the log line is
12
+ * serialized. That is the correct place to drop, pass through, or rewrite a log call.
13
+ *
14
+ * Usage:
15
+ * const { createPinoFeatures } = require('./lib/pinoHooks') // internal module, wired up by pino.js createLogger();
16
+ * const features = createPinoFeatures({ sampling, rateLimit, deduplication });
17
+ * const logger = pino(features.enabled ? { hooks: { logMethod: features.logMethod } } : {});
18
+ */
19
+
20
+ const { LogSampler } = require('./logSampler');
21
+ const { LogDeduplicator } = require('./logDeduplicator');
22
+ const NTL_LEVELS = require('./levels');
23
+
24
+ /**
25
+ * Pino's standard numeric levels mapped to NightTimeLogger level names.
26
+ * NTL has no numeric equivalent for `internal`; custom levels are resolved by name
27
+ * through the `levels` option (pino's `logger.levels.values`).
28
+ * @type {Readonly<Object<number, string>>}
29
+ */
30
+ const PINO_LEVEL_TO_NTL = Object.freeze({
31
+ 10: 'trace',
32
+ 20: 'debug',
33
+ 30: 'info',
34
+ 40: 'warn',
35
+ 50: 'error',
36
+ 60: 'fatal',
37
+ });
38
+
39
+ /** Maximum number of interpolation arguments folded into a dedup fingerprint. */
40
+ const MAX_FINGERPRINT_ARGS = 8;
41
+
42
+ /**
43
+ * Check whether a value is a plain-ish object (merge object candidate).
44
+ * @param {*} value - Value to test
45
+ * @returns {boolean} - True when the value can act as a Pino merge object
46
+ */
47
+ function isMergeObject(value) {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Error);
49
+ }
50
+
51
+ /**
52
+ * Normalize the `sampling` option into a plain level -> rate map.
53
+ * @param {object} sampling - Raw sampling config
54
+ * @returns {object} - Sanitized sampling map
55
+ */
56
+ function normalizeSampling(sampling) {
57
+ const out = {};
58
+ if (!sampling || typeof sampling !== 'object') {
59
+ return out;
60
+ }
61
+ for (const [level, rate] of Object.entries(sampling)) {
62
+ if (typeof rate === 'number' && Number.isFinite(rate)) {
63
+ out[level] = rate;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Normalize the `rateLimit` option into a plain level -> { max, window } map.
71
+ * Accepts `window` (documented) or `windowMs` (alias).
72
+ * @param {object} rateLimit - Raw rate limit config
73
+ * @returns {object} - Sanitized rate limit map
74
+ */
75
+ function normalizeRateLimit(rateLimit) {
76
+ const out = {};
77
+ if (!rateLimit || typeof rateLimit !== 'object') {
78
+ return out;
79
+ }
80
+ for (const [level, limit] of Object.entries(rateLimit)) {
81
+ if (!limit || typeof limit !== 'object') {
82
+ continue;
83
+ }
84
+ const max = typeof limit.max === 'number' ? limit.max : null;
85
+ const window = typeof limit.window === 'number'
86
+ ? limit.window
87
+ : (typeof limit.windowMs === 'number' ? limit.windowMs : 60000);
88
+ if (max === null || !Number.isFinite(max) || !Number.isFinite(window)) {
89
+ continue;
90
+ }
91
+ out[level] = { max, window };
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Normalize the `deduplication` option.
98
+ * Accepts `true`, or an object with `{ enabled, threshold, window | windowMs, levels }`.
99
+ * @param {object|boolean} deduplication - Raw deduplication config
100
+ * @returns {{enabled: boolean, threshold: number, window: number, levels: (string[]|null)}} - Sanitized config
101
+ */
102
+ function normalizeDeduplication(deduplication) {
103
+ const disabled = { enabled: false, threshold: 3, window: 60000, levels: null };
104
+
105
+ if (deduplication === true) {
106
+ return { enabled: true, threshold: 3, window: 60000, levels: null };
107
+ }
108
+ if (!deduplication || typeof deduplication !== 'object') {
109
+ return disabled;
110
+ }
111
+
112
+ const keys = Object.keys(deduplication);
113
+ if (keys.length === 0) {
114
+ return disabled;
115
+ }
116
+ if (deduplication.enabled === false) {
117
+ return disabled;
118
+ }
119
+
120
+ const window = typeof deduplication.window === 'number'
121
+ ? deduplication.window
122
+ : (typeof deduplication.windowMs === 'number' ? deduplication.windowMs : 60000);
123
+
124
+ return {
125
+ enabled: true,
126
+ threshold: typeof deduplication.threshold === 'number' ? deduplication.threshold : 3,
127
+ window,
128
+ levels: Array.isArray(deduplication.levels) ? deduplication.levels.slice() : null,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Build a numeric -> NTL level name resolver.
134
+ * Custom levels are resolved through the supplied pino `levels` map (name -> number);
135
+ * unresolvable levels return null, which means "pass through unfiltered".
136
+ * @param {object} levels - Pino `logger.levels.values` map (name -> number)
137
+ * @returns {function(number): (string|null)} - Resolver function
138
+ */
139
+ function createLevelResolver(levels) {
140
+ const byNumber = {};
141
+
142
+ if (levels && typeof levels === 'object') {
143
+ for (const [name, value] of Object.entries(levels)) {
144
+ if (typeof value === 'number' && Number.isFinite(value)) {
145
+ byNumber[value] = name;
146
+ }
147
+ }
148
+ }
149
+
150
+ return function resolveLevel(levelValue) {
151
+ if (typeof levelValue === 'string') {
152
+ return levelValue;
153
+ }
154
+ if (typeof levelValue !== 'number' || !Number.isFinite(levelValue)) {
155
+ return null;
156
+ }
157
+ const custom = byNumber[levelValue];
158
+ if (typeof custom === 'string') {
159
+ return custom;
160
+ }
161
+ const standard = PINO_LEVEL_TO_NTL[levelValue];
162
+ return typeof standard === 'string' ? standard : null;
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Locate the message argument inside the arguments Pino handed to `logMethod`.
168
+ *
169
+ * Supported shapes:
170
+ * (msg) -> index 0
171
+ * (msg, ...interpolationArgs) -> index 0
172
+ * (mergeObject, msg, ...args) -> index 1
173
+ * (error) -> message from error, insert rewrite at index 1
174
+ * (mergeObject) -> no message
175
+ *
176
+ * @param {Array} inputArgs - Arguments Pino passed to the hook
177
+ * @returns {{message: (string|null), index: number, insert: boolean, location: (string|null)}} - Extraction result
178
+ */
179
+ function extractMessage(inputArgs) {
180
+ const result = { message: null, index: -1, insert: false, location: null };
181
+
182
+ if (!inputArgs || inputArgs.length === 0) {
183
+ return result;
184
+ }
185
+
186
+ const first = inputArgs[0];
187
+
188
+ if (typeof first === 'string') {
189
+ result.message = first;
190
+ result.index = 0;
191
+ return result;
192
+ }
193
+
194
+ if (first instanceof Error) {
195
+ // logger.error(err) -> pino uses err.message as msg. Rewriting means adding an
196
+ // explicit msg argument, i.e. logger.error(err, 'boom (x3)').
197
+ if (typeof inputArgs[1] === 'string') {
198
+ result.message = inputArgs[1];
199
+ result.index = 1;
200
+ } else if (typeof first.message === 'string') {
201
+ result.message = first.message;
202
+ result.index = 1;
203
+ result.insert = true;
204
+ }
205
+ return result;
206
+ }
207
+
208
+ if (isMergeObject(first)) {
209
+ if (typeof first.module === 'string') {
210
+ result.location = first.module;
211
+ }
212
+ if (typeof inputArgs[1] === 'string') {
213
+ result.message = inputArgs[1];
214
+ result.index = 1;
215
+ }
216
+ return result;
217
+ }
218
+
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Build the string used to fingerprint a log call. Primitive interpolation arguments are
224
+ * folded in so that `info('user %s in', 'a')` and `info('user %s in', 'b')` stay distinct,
225
+ * while lib/messageNormalizer.js still collapses varying ids/uuids/numbers.
226
+ * Object arguments are reduced to a constant placeholder so nothing is retained.
227
+ * @param {string} message - The message string
228
+ * @param {Array} inputArgs - Arguments Pino passed to the hook
229
+ * @param {number} messageIndex - Index of the message argument (or insert position)
230
+ * @returns {string} - Fingerprint source string
231
+ */
232
+ function buildFingerprintSource(message, inputArgs, messageIndex) {
233
+ let source = message;
234
+ const start = messageIndex + 1;
235
+ const end = Math.min(inputArgs.length, start + MAX_FINGERPRINT_ARGS);
236
+
237
+ for (let i = start; i < end; i++) {
238
+ const arg = inputArgs[i];
239
+ const type = typeof arg;
240
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'bigint') {
241
+ source += ` ${String(arg)}`;
242
+ } else if (arg === null || arg === undefined) {
243
+ source += ' null';
244
+ } else {
245
+ source += ' [obj]';
246
+ }
247
+ }
248
+
249
+ return source;
250
+ }
251
+
252
+ /**
253
+ * Create the Pino feature adapter.
254
+ *
255
+ * @param {object} [options] - Options
256
+ * @param {object} [options.sampling] - Per-level sampling rates, e.g. { debug: 0.1, info: 0.5 }
257
+ * @param {object} [options.rateLimit] - Per-level rate limits, e.g. { info: { max: 100, window: 60000 } }
258
+ * @param {object|boolean} [options.deduplication] - { enabled, threshold, window|windowMs, levels } or true
259
+ * @param {object} [options.levels] - Pino `logger.levels.values` map (name -> number)
260
+ * @param {string} [options.defaultLocation] - Fallback dedup location (mixin output is not in inputArgs)
261
+ * @param {object} [options.sampler] - Pre-built sampler (primarily for testing)
262
+ * @param {object} [options.deduplicator] - Pre-built deduplicator (primarily for testing)
263
+ * @returns {object} - { enabled, logMethod, getStats, resetStats, destroy, sampler, deduplicator }
264
+ */
265
+ function createPinoFeatures(options = {}) {
266
+ const opts = options || {};
267
+
268
+ const sampling = normalizeSampling(opts.sampling);
269
+ const rateLimit = normalizeRateLimit(opts.rateLimit);
270
+ const dedupConfig = normalizeDeduplication(opts.deduplication);
271
+
272
+ const hasSamplerConfig = Object.keys(sampling).length > 0 || Object.keys(rateLimit).length > 0;
273
+
274
+ const sampler = opts.sampler !== undefined && opts.sampler !== null
275
+ ? opts.sampler
276
+ : (hasSamplerConfig ? new LogSampler({ sampling, rateLimit }) : null);
277
+
278
+ const deduplicator = opts.deduplicator !== undefined && opts.deduplicator !== null
279
+ ? opts.deduplicator
280
+ : (dedupConfig.enabled
281
+ ? new LogDeduplicator({
282
+ enabled: true,
283
+ threshold: dedupConfig.threshold,
284
+ window: dedupConfig.window,
285
+ })
286
+ : null);
287
+
288
+ const defaultLocation = typeof opts.defaultLocation === 'string' ? opts.defaultLocation : null;
289
+ const resolveLevel = createLevelResolver(opts.levels);
290
+
291
+ // `fatal` is never dropped unless the configuration explicitly opts it in.
292
+ const fatalSamplerExplicit = Object.prototype.hasOwnProperty.call(sampling, 'fatal')
293
+ || Object.prototype.hasOwnProperty.call(rateLimit, 'fatal');
294
+ // Opting fatal in requires either listing it in `deduplication.levels` or `deduplication.fatal: true`.
295
+ const fatalDedupExplicit = (Array.isArray(dedupConfig.levels) && dedupConfig.levels.indexOf('fatal') !== -1)
296
+ || Boolean(opts.deduplication && typeof opts.deduplication === 'object' && opts.deduplication.fatal === true);
297
+
298
+ const dedupLevels = dedupConfig.levels;
299
+
300
+ /**
301
+ * Check whether deduplication applies to a level.
302
+ * @param {string} level - NTL level name
303
+ * @returns {boolean} - True when the level should be deduplicated
304
+ */
305
+ function dedupAppliesTo(level) {
306
+ if (dedupLevels && dedupLevels.indexOf(level) === -1) {
307
+ return false;
308
+ }
309
+ if (level === 'fatal' && !fatalDedupExplicit) {
310
+ return false;
311
+ }
312
+ return true;
313
+ }
314
+
315
+ /**
316
+ * Pino `hooks.logMethod` implementation.
317
+ * MUST be invoked with `this` bound to the pino logger instance (Pino does this).
318
+ * Never throws: any internal failure falls back to emitting the original log call.
319
+ *
320
+ * @param {Array} inputArgs - Arguments passed to the log method
321
+ * @param {Function} method - The underlying pino log method
322
+ * @param {number} level - Numeric pino level
323
+ * @returns {*} - Result of the underlying log method, or undefined when dropped
324
+ */
325
+ function logMethod(inputArgs, method, level) {
326
+ let emitted = false;
327
+
328
+ try {
329
+ const ntlLevel = resolveLevel(level);
330
+
331
+ // Unknown/custom level that cannot be resolved: pass through unfiltered.
332
+ if (ntlLevel === null) {
333
+ emitted = true;
334
+ return method.apply(this, inputArgs);
335
+ }
336
+
337
+ const isFatal = ntlLevel === 'fatal';
338
+
339
+ // Rate limiting then sampling (LogSampler#shouldProcess applies them in that order).
340
+ if (sampler && (!isFatal || fatalSamplerExplicit)) {
341
+ if (sampler.shouldProcess(ntlLevel) === false) {
342
+ return undefined;
343
+ }
344
+ }
345
+
346
+ // Deduplication.
347
+ if (deduplicator && dedupAppliesTo(ntlLevel)) {
348
+ const extracted = extractMessage(inputArgs);
349
+ if (extracted.message !== null) {
350
+ const fingerprintSource = buildFingerprintSource(
351
+ extracted.message,
352
+ inputArgs,
353
+ extracted.insert ? extracted.index - 1 : extracted.index
354
+ );
355
+ // Meta is intentionally a fresh, tiny object: LogDeduplicator retains it as
356
+ // part of its entry sample, so no logged payload may be referenced here.
357
+ const decision = deduplicator.check(ntlLevel, fingerprintSource, {
358
+ location: extracted.location || defaultLocation || undefined,
359
+ });
360
+
361
+ if (decision) {
362
+ if (decision.shouldLog === false) {
363
+ return undefined;
364
+ }
365
+ if (decision.count > 1 && decision.message !== fingerprintSource) {
366
+ const suffix = decision.message.slice(fingerprintSource.length);
367
+ if (suffix) {
368
+ const newArgs = inputArgs.slice();
369
+ if (extracted.insert) {
370
+ newArgs.splice(extracted.index, 0, extracted.message + suffix);
371
+ } else {
372
+ newArgs[extracted.index] = extracted.message + suffix;
373
+ }
374
+ emitted = true;
375
+ return method.apply(this, newArgs);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ emitted = true;
383
+ return method.apply(this, inputArgs);
384
+ } catch (err) {
385
+ if (emitted) {
386
+ // The failure came from the log call itself; behave as if no hook was installed.
387
+ throw err;
388
+ }
389
+ // A bug in the feature pipeline must never lose a log line.
390
+ return method.apply(this, inputArgs);
391
+ }
392
+ }
393
+
394
+ return {
395
+ enabled: Boolean(sampler || deduplicator),
396
+ sampler,
397
+ deduplicator,
398
+ logMethod,
399
+
400
+ /**
401
+ * Get feature statistics.
402
+ * @returns {{sampling: (object|null), deduplication: (object|null)}} - Stats snapshot
403
+ */
404
+ getStats() {
405
+ return {
406
+ sampling: sampler && typeof sampler.getStats === 'function' ? sampler.getStats() : null,
407
+ deduplication: deduplicator && typeof deduplicator.getStats === 'function'
408
+ ? deduplicator.getStats()
409
+ : null,
410
+ };
411
+ },
412
+
413
+ /**
414
+ * Reset feature statistics.
415
+ * @returns {void}
416
+ */
417
+ resetStats() {
418
+ if (sampler && typeof sampler.resetStats === 'function') {
419
+ sampler.resetStats();
420
+ }
421
+ if (deduplicator && typeof deduplicator.resetStats === 'function') {
422
+ deduplicator.resetStats();
423
+ }
424
+ },
425
+
426
+ /**
427
+ * Release timers and internal state so the process can exit cleanly.
428
+ * @returns {void}
429
+ */
430
+ destroy() {
431
+ if (sampler && typeof sampler.destroy === 'function') {
432
+ sampler.destroy();
433
+ }
434
+ if (deduplicator && typeof deduplicator.destroy === 'function') {
435
+ deduplicator.destroy();
436
+ }
437
+ },
438
+ };
439
+ }
440
+
441
+ module.exports = {
442
+ createPinoFeatures,
443
+ PINO_LEVEL_TO_NTL,
444
+ extractMessage,
445
+ normalizeDeduplication,
446
+ };