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.
package/pino.js CHANGED
@@ -1,28 +1,433 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * @file pino.js
5
+ * @description Entry point for `require('ntlogger/pino')`.
6
+ *
7
+ * Exports (in addition to everything `transports/pino.js` exports):
8
+ * - createLogger(opts) pre-configured Pino logger with NTL conventions
9
+ * - isSilent(opts) silent-mode resolution, exported for testability
10
+ * - installProcessHandlers() re-export of lib/processHandlers.js
11
+ * - drainLogger() re-export of lib/processHandlers.js
12
+ *
13
+ * The default export is the transport factory itself, so
14
+ * `transport: { target: 'ntlogger/pino' }` keeps working.
15
+ *
16
+ * NOTE: lib/processHandlers.js is required here and *never* from
17
+ * transports/pino.js — the transport runs inside Pino's worker thread, where
18
+ * installing process handlers would be meaningless and harmful.
19
+ */
20
+
3
21
  // Re-export the transport for use with Pino's transport option
4
22
  // Usage: transport: { target: 'ntlogger/pino' }
5
23
  module.exports = require('./transports/pino');
6
24
 
7
- // Helper for creating a pre-configured Pino instance
8
- module.exports.createLogger = function (opts = {}) {
25
+ const { installProcessHandlers, drainLogger } = require('./lib/processHandlers');
26
+
27
+ module.exports.installProcessHandlers = installProcessHandlers;
28
+ module.exports.drainLogger = drainLogger;
29
+
30
+ /** Values of NTLOGGER_SILENT that force silent mode on/off. */
31
+ const SILENT_TRUE = new Set(['1', 'true', 'yes']);
32
+ const SILENT_FALSE = new Set(['0', 'false', 'no']);
33
+
34
+ /** Pino's default error key. A merge object's `err` supplies `msg` when none is given. */
35
+ const ERROR_KEY = 'err';
36
+
37
+ /** Default budget for `logger.close()`. */
38
+ const DEFAULT_CLOSE_TIMEOUT = 5000;
39
+
40
+ /** A string is treated as a Pino (fast-redact) path when it contains any of these. */
41
+ const PINO_PATH_CHARS = /[.[\]*]/;
42
+
43
+ let contextWarned = false;
44
+
45
+ /**
46
+ * Emit at most one process warning when a user-supplied context provider throws.
47
+ * @param {Error} err - The swallowed error.
48
+ * @returns {void}
49
+ */
50
+ function warnContextOnce(err) {
51
+ if (contextWarned) return;
52
+ contextWarned = true;
53
+ try {
54
+ process.emitWarning(
55
+ 'contextProvider threw, context dropped for this and future failures: ' + ((err && err.message) || err),
56
+ 'NTLoggerContextWarning'
57
+ );
58
+ } catch (_ignored) {
59
+ // Warning is best-effort.
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Plain object / array test. Used to decide whether an interpolation argument
65
+ * is worth deep-redacting (Errors and class instances are left alone so their
66
+ * `util.format` rendering is unchanged).
67
+ * @param {*} value - Candidate value.
68
+ * @returns {boolean} - True for array literals and plain objects.
69
+ */
70
+ function isPlainContainer(value) {
71
+ if (value === null || typeof value !== 'object') return false;
72
+ if (Array.isArray(value)) return true;
73
+ const proto = Object.getPrototypeOf(value);
74
+ return proto === Object.prototype || proto === null;
75
+ }
76
+
77
+ /**
78
+ * Resolve silent mode.
79
+ *
80
+ * Precedence:
81
+ * 1. `opts.silent` when it is a boolean
82
+ * 2. `NTLOGGER_SILENT` env var (`1|true|yes` / `0|false|no`, case-insensitive)
83
+ * 3. `NODE_ENV === 'test'`
84
+ *
85
+ * Anything else (unset, or an unrecognised env value) falls through to the next step.
86
+ *
87
+ * @param {object} [opts] - createLogger options.
88
+ * @returns {boolean} - True when the logger must emit nothing.
89
+ */
90
+ function isSilent(opts = {}) {
91
+ if (typeof opts.silent === 'boolean') return opts.silent;
92
+
93
+ const raw = process.env.NTLOGGER_SILENT;
94
+ if (typeof raw === 'string' && raw.length > 0) {
95
+ const value = raw.trim().toLowerCase();
96
+ if (SILENT_TRUE.has(value)) return true;
97
+ if (SILENT_FALSE.has(value)) return false;
98
+ }
99
+
100
+ return process.env.NODE_ENV === 'test';
101
+ }
102
+
103
+ /**
104
+ * Normalize `opts.redact` into a local redactor plus (optionally) paths that are
105
+ * forwarded to Pino's own `redact` option.
106
+ *
107
+ * Accepted shapes:
108
+ * false -> redaction disabled entirely
109
+ * undefined -> defaults from lib/secretRedaction.js
110
+ * string[] -> entries containing `.`/`[`/`]`/`*` become Pino redact paths,
111
+ * the rest become `extraKeys` for the local redactor
112
+ * object -> passed straight to createRedactor()
113
+ *
114
+ * @param {*} redact - Raw option value.
115
+ * @returns {{redactor: (object|null), paths: (string[]|null)}} - Resolved redaction.
116
+ */
117
+ function resolveRedaction(redact) {
118
+ if (redact === false) return { redactor: null, paths: null };
119
+
120
+ const { createRedactor } = require('./lib/secretRedaction');
121
+
122
+ if (Array.isArray(redact)) {
123
+ const paths = [];
124
+ const extraKeys = [];
125
+ for (const entry of redact) {
126
+ if (typeof entry !== 'string' || entry.length === 0) continue;
127
+ if (PINO_PATH_CHARS.test(entry)) paths.push(entry);
128
+ else extraKeys.push(entry);
129
+ }
130
+ return {
131
+ redactor: createRedactor(extraKeys.length > 0 ? { extraKeys } : undefined),
132
+ paths: paths.length > 0 ? paths : null,
133
+ };
134
+ }
135
+
136
+ if (redact !== null && typeof redact === 'object') {
137
+ return { redactor: createRedactor(redact), paths: null };
138
+ }
139
+
140
+ return { redactor: createRedactor(), paths: null };
141
+ }
142
+
143
+ /**
144
+ * Redact the free-form string parts of a log call, in place.
145
+ *
146
+ * `formatters.log` only sees the merged object, never the message string or its
147
+ * interpolation arguments, so those have to be handled in the `logMethod` hook.
148
+ * Pino builds a fresh array for every call, so mutating `args` is safe.
149
+ *
150
+ * Two cases need an explicit message to be *inserted*: `log.error(err)` and
151
+ * `log.error({ err })` both take `msg` from `err.message`, which bypasses
152
+ * `formatters.log`. Inserting is skipped when redaction is a no-op so ordinary
153
+ * calls keep their exact original shape.
154
+ *
155
+ * @param {Array} args - Arguments Pino passed to `hooks.logMethod`.
156
+ * @param {object} redactor - Redactor from lib/secretRedaction.js.
157
+ * @returns {Array} - The same array, redacted.
158
+ */
159
+ function redactLogArgs(args, redactor) {
160
+ if (!args || args.length === 0) return args;
161
+
162
+ const first = args[0];
163
+ let start = 0;
164
+
165
+ if (first instanceof Error) {
166
+ start = 1;
167
+ if (typeof args[1] !== 'string' && typeof first.message === 'string') {
168
+ const redacted = redactor.redactString(first.message);
169
+ if (redacted !== first.message) args.splice(1, 0, redacted);
170
+ }
171
+ } else if (first !== null && typeof first === 'object' && !Array.isArray(first)) {
172
+ // Merge object: its own keys are handled by formatters.log.
173
+ start = 1;
174
+ if (typeof args[1] !== 'string'
175
+ && first.msg === undefined
176
+ && first[ERROR_KEY] instanceof Error
177
+ && typeof first[ERROR_KEY].message === 'string') {
178
+ const redacted = redactor.redactString(first[ERROR_KEY].message);
179
+ if (redacted !== first[ERROR_KEY].message) args.splice(1, 0, redacted);
180
+ }
181
+ }
182
+
183
+ for (let i = start; i < args.length; i++) {
184
+ const arg = args[i];
185
+ if (typeof arg === 'string') args[i] = redactor.redactString(arg);
186
+ else if (isPlainContainer(arg)) args[i] = redactor.redactObject(arg);
187
+ }
188
+
189
+ return args;
190
+ }
191
+
192
+ /**
193
+ * Redact a merge object for `formatters.log`.
194
+ *
195
+ * `formatters.log` runs *before* Pino's serializers (see `_asJson` in
196
+ * pino/lib/tools.js), so a real `Error` under the error key must survive this
197
+ * pass untouched — otherwise Pino's `err` serializer receives a plain object
198
+ * and reports `"type":"Object"`. The error itself is redacted by the `err`
199
+ * serializer installed alongside this formatter.
200
+ *
201
+ * Errors under any *other* key are still deep-redacted here, which is strictly
202
+ * better than Pino's default (`JSON.stringify(new Error())` is `{}`).
203
+ *
204
+ * @param {object} obj - Merged log object.
205
+ * @param {object} redactor - Redactor from lib/secretRedaction.js.
206
+ * @returns {object} - Redacted copy.
207
+ */
208
+ function redactMergeObject(obj, redactor) {
209
+ if (obj === null || typeof obj !== 'object' || !(obj[ERROR_KEY] instanceof Error)) {
210
+ return redactor.redactObject(obj);
211
+ }
212
+
213
+ const keys = Object.keys(obj);
214
+ const stripped = {};
215
+ for (const key of keys) {
216
+ if (key !== ERROR_KEY) stripped[key] = obj[key];
217
+ }
218
+
219
+ const redacted = redactor.redactObject(stripped);
220
+ const out = {};
221
+ for (const key of keys) {
222
+ out[key] = key === ERROR_KEY ? obj[key] : redacted[key];
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * Build the single `hooks.logMethod` Pino allows, composing string redaction
229
+ * with the sampling/rate-limit/dedup pipeline from lib/pinoHooks.js.
230
+ *
231
+ * @param {object|null} redactor - Redactor, or null when redaction is off.
232
+ * @param {object|null} features - Feature adapter, or null when nothing is configured.
233
+ * @returns {function|null} - Hook function, or null when neither is active.
234
+ */
235
+ function composeLogMethod(redactor, features) {
236
+ const featureLog = features && features.enabled ? features.logMethod : null;
237
+ if (!redactor && !featureLog) return null;
238
+
239
+ return function logMethod(args, method, level) {
240
+ if (redactor) redactLogArgs(args, redactor);
241
+ if (featureLog) return featureLog.call(this, args, method, level);
242
+ return method.apply(this, args);
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Attach ntlogger's additive API to a Pino logger.
248
+ *
249
+ * Everything is defined as a *non-enumerable own property* of the returned
250
+ * instance. Pino's `child()` does `Object.create(parent)`, so children inherit
251
+ * these through the prototype chain without anything being written to Pino's
252
+ * shared prototype (which would leak across unrelated Pino instances).
253
+ *
254
+ * `child()` itself is overridden so child bindings are redacted: Pino resets
255
+ * `formatters.bindings` to the identity function inside `child()`, so that
256
+ * formatter only ever sees `base`.
257
+ *
258
+ * @param {object} logger - Pino logger instance.
259
+ * @param {object|null} features - Feature adapter from lib/pinoHooks.js.
260
+ * @param {object|null} redactor - Redactor from lib/secretRedaction.js.
261
+ * @returns {object} - The same logger, decorated.
262
+ */
263
+ function decorateLogger(logger, features, redactor) {
264
+ const define = (name, value) => {
265
+ Object.defineProperty(logger, name, {
266
+ value,
267
+ writable: true,
268
+ configurable: true,
269
+ enumerable: false,
270
+ });
271
+ };
272
+
273
+ define('getStats', function getStats() {
274
+ return features ? features.getStats() : { sampling: null, deduplication: null };
275
+ });
276
+
277
+ define('resetStats', function resetStats() {
278
+ if (features) features.resetStats();
279
+ });
280
+
281
+ // Releases the sampler/deduplicator timers, then flushes whatever the
282
+ // destination still holds. Calling it on a child tears down the state shared
283
+ // with its parent — close the root logger.
284
+ define('close', function close(timeout) {
285
+ if (features) features.destroy();
286
+ return drainLogger(this, typeof timeout === 'number' ? timeout : DEFAULT_CLOSE_TIMEOUT);
287
+ });
288
+
289
+ if (redactor) {
290
+ const baseChild = logger.child;
291
+ define('child', function child(bindings, options) {
292
+ const safe = bindings !== null && typeof bindings === 'object'
293
+ ? redactor.redactObject(bindings)
294
+ : bindings;
295
+ return baseChild.call(this, safe, options);
296
+ });
297
+ }
298
+
299
+ return logger;
300
+ }
301
+
302
+ /**
303
+ * Create a pre-configured Pino logger that follows NightTimeLogger conventions.
304
+ *
305
+ * Returns a *real* Pino logger, so the call signature is object-first:
306
+ * `log.info({ tenantId }, 'message')`.
307
+ *
308
+ * @param {object} [opts] - Options.
309
+ * @param {string} [opts.level] - Pino level. Defaults to `LOG_LEVEL`, else `debug` in dev / `info` in production.
310
+ * @param {string} [opts.module] - Module label added to every line and used as the transport's default label.
311
+ * @param {string} [opts.defaultModule] - Transport label fallback when `module` is not set.
312
+ * @param {boolean} [opts.colorize] - Forwarded to the NTL transport.
313
+ * @param {string} [opts.service] - Constant top-level `service` field (log-contract core field).
314
+ * @param {object} [opts.context] - Static top-level fields merged into every line.
315
+ * @param {function} [opts.contextProvider] - Called per log call; its object result is merged at top level.
316
+ * @param {string[]} [opts.contextKeys] - When set, only these keys are taken from the provider result.
317
+ * @param {false|string[]|object} [opts.redact] - Secret redaction. Defaults to on.
318
+ * @param {object} [opts.sampling] - Per-level sampling rates.
319
+ * @param {object} [opts.rateLimit] - Per-level rate limits.
320
+ * @param {object|boolean} [opts.deduplication] - Duplicate-message collapsing.
321
+ * @param {boolean} [opts.silent] - Force silent mode on/off (see isSilent()).
322
+ * @param {boolean|object} [opts.processHandlers] - Opt-in crash/signal handlers.
323
+ * @param {*} [opts.destination] - For tests/advanced use: a Pino destination stream. Replaces the NTL transport.
324
+ * @returns {object} - Pino logger with `getStats()`, `resetStats()`, `close()` and (optionally) `uninstallProcessHandlers`.
325
+ */
326
+ function createLogger(opts = {}) {
9
327
  const pino = require('pino');
10
328
  const path = require('path');
329
+ const os = require('os');
11
330
 
331
+ const silent = isSilent(opts);
12
332
  const isDev = process.env.NODE_ENV !== 'production';
13
- const level = opts.level || process.env.LOG_LEVEL || (isDev ? 'debug' : 'info');
333
+ const level = silent
334
+ ? 'silent'
335
+ : (opts.level || process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'));
14
336
 
15
- const loggerOpts = {
16
- level,
17
- };
337
+ const loggerOpts = { level };
338
+
339
+ // --- constant fields ---------------------------------------------------
340
+ if (typeof opts.service === 'string' && opts.service.length > 0) {
341
+ // `base` replaces Pino's default, so pid/hostname have to be restored.
342
+ loggerOpts.base = { pid: process.pid, hostname: os.hostname(), service: opts.service };
343
+ }
344
+
345
+ // --- redaction / features (skipped entirely in silent mode) ------------
346
+ let redactor = null;
347
+ let features = null;
348
+
349
+ if (!silent) {
350
+ const redaction = resolveRedaction(opts.redact);
351
+ redactor = redaction.redactor;
352
+ if (redaction.paths) loggerOpts.redact = redaction.paths;
353
+
354
+ if (redactor) {
355
+ loggerOpts.formatters = {
356
+ log: obj => redactMergeObject(obj, redactor),
357
+ // Only ever sees `base`: Pino resets this formatter to the identity
358
+ // function inside child(). Child bindings are handled by the child()
359
+ // override in decorateLogger().
360
+ bindings: bindings => redactor.redactObject(bindings),
361
+ };
362
+ // Runs after formatters.log, on the untouched Error, so `type`/`stack`
363
+ // keep Pino's exact shape while their strings get scrubbed.
364
+ loggerOpts.serializers = {
365
+ err: value => (value instanceof Error
366
+ ? redactor.redactObject(pino.stdSerializers.err(value))
367
+ : value),
368
+ };
369
+ }
370
+
371
+ const { createPinoFeatures } = require('./lib/pinoHooks');
372
+ features = createPinoFeatures({
373
+ sampling: opts.sampling,
374
+ rateLimit: opts.rateLimit,
375
+ deduplication: opts.deduplication,
376
+ defaultLocation: opts.module || opts.defaultModule,
377
+ });
378
+
379
+ const logMethod = composeLogMethod(redactor, features);
380
+ if (logMethod) loggerOpts.hooks = { logMethod };
381
+ }
382
+
383
+ // --- context mixin ------------------------------------------------------
384
+ const staticContext = opts.context !== null && typeof opts.context === 'object' && !Array.isArray(opts.context)
385
+ ? Object.assign({}, opts.context)
386
+ : null;
387
+ const provider = typeof opts.contextProvider === 'function' ? opts.contextProvider : null;
388
+ const contextKeys = Array.isArray(opts.contextKeys) && opts.contextKeys.length > 0
389
+ ? opts.contextKeys.slice()
390
+ : null;
18
391
 
19
- // Add module name as a mixin if provided
20
- if (opts.module) {
21
- loggerOpts.mixin = () => ({ module: opts.module });
392
+ if (!silent && (opts.module || staticContext || provider)) {
393
+ // Precedence, lowest to highest: base/service < module < context < contextProvider
394
+ // < fields passed on the log call itself (Pino's default mixin merge strategy
395
+ // assigns the merge object over the mixin output).
396
+ loggerOpts.mixin = () => {
397
+ const out = {};
398
+ if (opts.module) out.module = opts.module;
399
+ if (staticContext) Object.assign(out, staticContext);
400
+
401
+ if (provider) {
402
+ let dynamic;
403
+ try {
404
+ dynamic = provider();
405
+ } catch (err) {
406
+ warnContextOnce(err);
407
+ dynamic = null;
408
+ }
409
+ if (dynamic !== null && typeof dynamic === 'object' && !Array.isArray(dynamic)) {
410
+ if (contextKeys) {
411
+ for (const key of contextKeys) {
412
+ const value = dynamic[key];
413
+ if (value !== undefined) out[key] = value;
414
+ }
415
+ } else {
416
+ Object.assign(out, dynamic);
417
+ }
418
+ }
419
+ }
420
+
421
+ return out;
422
+ };
22
423
  }
23
424
 
24
- // In dev: use NTL formatter. In production: plain JSON.
25
- if (isDev) {
425
+ // --- destination / transport -------------------------------------------
426
+ // `opts.destination` and `transport` are mutually exclusive in Pino, so an
427
+ // explicit destination always wins and the NTL formatter is skipped.
428
+ const destination = opts.destination !== undefined && opts.destination !== null ? opts.destination : null;
429
+
430
+ if (!silent && !destination && isDev) {
26
431
  loggerOpts.transport = {
27
432
  target: path.join(__dirname, 'transports', 'pino.js'),
28
433
  options: {
@@ -32,5 +437,21 @@ module.exports.createLogger = function (opts = {}) {
32
437
  };
33
438
  }
34
439
 
35
- return pino(loggerOpts);
36
- };
440
+ const logger = destination ? pino(loggerOpts, destination) : pino(loggerOpts);
441
+ decorateLogger(logger, features, redactor);
442
+
443
+ if (opts.processHandlers) {
444
+ const handlerOpts = typeof opts.processHandlers === 'object' ? opts.processHandlers : {};
445
+ Object.defineProperty(logger, 'uninstallProcessHandlers', {
446
+ value: installProcessHandlers(logger, handlerOpts),
447
+ writable: true,
448
+ configurable: true,
449
+ enumerable: false,
450
+ });
451
+ }
452
+
453
+ return logger;
454
+ }
455
+
456
+ module.exports.createLogger = createLogger;
457
+ module.exports.isSilent = isSilent;