ntlogger 2.10.0 → 4.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,467 @@
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'` or a nonempty `NODE_TEST_CONTEXT`
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' || Boolean(process.env.NODE_TEST_CONTEXT);
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
+ * @param {object} observer - Emitted-record observer and counters.
262
+ * @returns {object} - Pino logger proxy enforcing lifecycle across level changes.
263
+ */
264
+ function decorateLogger(logger, features, redactor, observer) {
265
+ const states = new WeakMap();
266
+ const define = (name, value) => Object.defineProperty(logger, name, {
267
+ value, writable: true, configurable: true, enumerable: false,
268
+ });
269
+ define('getStats', () => ({
270
+ ...(features ? features.getStats() : { sampling: null, deduplication: null }),
271
+ ...observer.stats(),
272
+ }));
273
+ define('resetStats', () => { if (features) features.resetStats(); observer.reset(); });
274
+ define('close', function close(timeout = DEFAULT_CLOSE_TIMEOUT) {
275
+ const state = states.get(this);
276
+ if (state.promise) return state.promise;
277
+ state.closed = true;
278
+ if (!state.parent && features) features.destroy();
279
+ state.promise = (async () => {
280
+ const start = Date.now();
281
+ const result = await drainLogger(this, timeout);
282
+ try { await observer.flush(Math.max(1, timeout - (Date.now() - start))); }
283
+ catch (error) { return { drained: false, error }; }
284
+ return result;
285
+ })();
286
+ return state.promise;
287
+ });
288
+ const baseChild = logger.child;
289
+ define('child', function child(bindings, options) {
290
+ const safe = redactor && bindings && typeof bindings === 'object'
291
+ ? redactor.redactObject(bindings) : bindings;
292
+ return wrap(baseChild.call(this, safe, options), states.get(this));
293
+ });
294
+ function wrap(target, parent = null) {
295
+ const state = { closed: false, parent, promise: null };
296
+ states.set(target, state);
297
+ const assertOpen = () => {
298
+ for (let current = state; current; current = current.parent) {
299
+ if (current.closed) throw new Error('Logger is closed');
300
+ }
301
+ };
302
+ const cache = new Map();
303
+ return new Proxy(target, {
304
+ get(raw, key) {
305
+ const value = Reflect.get(raw, key, raw);
306
+ if (typeof value !== 'function') return value;
307
+ const guarded = key === 'child' || key === 'silent' || Object.hasOwn(raw.levels.values, key);
308
+ // Pino regenerates methods on level changes. Cache only while unchanged.
309
+ if (cache.get(key)?.original === value) return cache.get(key).bound;
310
+ const bound = (...args) => { if (guarded) assertOpen(); return value.apply(raw, args); };
311
+ cache.set(key, { original: value, bound });
312
+ return bound;
313
+ },
314
+ });
315
+ }
316
+ return wrap(logger);
317
+ }
318
+
319
+ /**
320
+ * Create a pre-configured Pino logger that follows NightTimeLogger conventions.
321
+ *
322
+ * Returns a *real* Pino logger, so the call signature is object-first:
323
+ * `log.info({ tenantId }, 'message')`.
324
+ *
325
+ * @param {object} [opts] - Options.
326
+ * @param {string} [opts.level] - Pino level. Defaults to `LOG_LEVEL`, else `debug` in dev / `info` in production.
327
+ * @param {string} [opts.module] - Module label added to every line and used as the transport's default label.
328
+ * @param {string} [opts.defaultModule] - Transport label fallback when `module` is not set.
329
+ * @param {boolean} [opts.colorize] - Forwarded to the NTL transport.
330
+ * @param {string} [opts.service] - Constant top-level `service` field (log-contract core field).
331
+ * @param {object} [opts.context] - Static top-level fields merged into every line.
332
+ * @param {function} [opts.contextProvider] - Called per log call; its object result is merged at top level.
333
+ * @param {string[]} [opts.contextKeys] - When set, only these keys are taken from the provider result.
334
+ * @param {false|string[]|object} [opts.redact] - Secret redaction. Defaults to on.
335
+ * @param {object} [opts.sampling] - Per-level sampling rates.
336
+ * @param {object} [opts.rateLimit] - Per-level rate limits.
337
+ * @param {object|boolean} [opts.deduplication] - Duplicate-message collapsing.
338
+ * @param {boolean} [opts.silent] - Force silent mode on/off (see isSilent()).
339
+ * @param {boolean|object} [opts.processHandlers] - Opt-in crash/signal handlers.
340
+ * @param {*} [opts.destination] - For tests/advanced use: a Pino destination stream. Replaces the NTL transport.
341
+ * @returns {object} - Pino logger with `getStats()`, `resetStats()`, `close()` and (optionally) `uninstallProcessHandlers`.
342
+ */
343
+ function buildPinoOptions(opts = {}) {
9
344
  const pino = require('pino');
10
- const path = require('path');
345
+ const os = require('os');
11
346
 
347
+ const silent = isSilent(opts);
12
348
  const isDev = process.env.NODE_ENV !== 'production';
13
- const level = opts.level || process.env.LOG_LEVEL || (isDev ? 'debug' : 'info');
349
+ const level = silent
350
+ ? 'silent'
351
+ : (opts.level || process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'));
352
+
353
+ const observer = require('./lib/logObserver').createLogObserver(opts);
354
+ const loggerOpts = { level, hooks: { streamWrite: observer.streamWrite } };
14
355
 
15
- const loggerOpts = {
16
- level,
356
+ // --- constant fields ---------------------------------------------------
357
+ if (typeof opts.service === 'string' && opts.service.length > 0) {
358
+ // `base` replaces Pino's default, so pid/hostname have to be restored.
359
+ loggerOpts.base = { pid: process.pid, hostname: os.hostname(), service: opts.service };
360
+ }
361
+
362
+ // --- redaction / features (skipped entirely in silent mode) ------------
363
+ let redactor = null;
364
+ let features = null;
365
+ const secretScope = require('./lib/secretScope');
366
+ loggerOpts.hooks.streamWrite = line => {
367
+ // Interpolation can stringify Errors/Buffers after logMethod has run. Scrub
368
+ // final string fields while a secret scope is active, before observers see them.
369
+ if (redactor && secretScope.hasSecretValues()) {
370
+ line = JSON.stringify(redactor.redactObject(JSON.parse(line))) + '\n';
371
+ }
372
+ return observer.streamWrite(line);
17
373
  };
18
374
 
19
- // Add module name as a mixin if provided
20
- if (opts.module) {
21
- loggerOpts.mixin = () => ({ module: opts.module });
375
+ if (!silent) {
376
+ const redaction = resolveRedaction(opts.redact);
377
+ redactor = redaction.redactor;
378
+ if (redaction.paths) loggerOpts.redact = redaction.paths;
379
+
380
+ if (redactor) {
381
+ loggerOpts.formatters = {
382
+ log: obj => redactMergeObject(obj, redactor),
383
+ // Only ever sees `base`: Pino resets this formatter to the identity
384
+ // function inside child(). Child bindings are handled by the child()
385
+ // override in decorateLogger().
386
+ bindings: bindings => redactor.redactObject(bindings),
387
+ };
388
+ // Runs after formatters.log, on the untouched Error, so `type`/`stack`
389
+ // keep Pino's exact shape while their strings get scrubbed.
390
+ loggerOpts.serializers = {
391
+ err: value => (value instanceof Error
392
+ ? redactor.redactObject(pino.stdSerializers.err(value))
393
+ : value),
394
+ };
395
+ }
396
+
397
+ const { createPinoFeatures } = require('./lib/pinoHooks');
398
+ features = createPinoFeatures({
399
+ sampling: opts.sampling,
400
+ rateLimit: opts.rateLimit,
401
+ deduplication: opts.deduplication,
402
+ defaultLocation: opts.module || opts.defaultModule,
403
+ });
404
+
405
+ const logMethod = composeLogMethod(redactor, features);
406
+ if (logMethod) loggerOpts.hooks.logMethod = logMethod;
22
407
  }
23
408
 
24
- // In dev: use NTL formatter. In production: plain JSON.
25
- if (isDev) {
409
+ // --- context mixin ------------------------------------------------------
410
+ const staticContext = opts.context !== null && typeof opts.context === 'object' && !Array.isArray(opts.context)
411
+ ? Object.assign({}, opts.context)
412
+ : null;
413
+ const provider = typeof opts.contextProvider === 'function' ? opts.contextProvider : null;
414
+ const contextKeys = Array.isArray(opts.contextKeys) && opts.contextKeys.length > 0
415
+ ? opts.contextKeys.slice()
416
+ : null;
417
+
418
+ if (!silent && (opts.module || staticContext || provider || opts.otel)) {
419
+ // Precedence, lowest to highest: base/service < module < context < contextProvider
420
+ // < fields passed on the log call itself (Pino's default mixin merge strategy
421
+ // assigns the merge object over the mixin output).
422
+ loggerOpts.mixin = () => {
423
+ const out = {};
424
+ if (opts.module) out.module = opts.module;
425
+ if (staticContext) Object.assign(out, staticContext);
426
+
427
+ if (provider) {
428
+ let dynamic;
429
+ try {
430
+ dynamic = provider();
431
+ } catch (err) {
432
+ warnContextOnce(err);
433
+ dynamic = null;
434
+ }
435
+ if (dynamic !== null && typeof dynamic === 'object' && !Array.isArray(dynamic)) {
436
+ if (contextKeys) {
437
+ for (const key of contextKeys) {
438
+ const value = dynamic[key];
439
+ if (value !== undefined) out[key] = value;
440
+ }
441
+ } else {
442
+ Object.assign(out, dynamic);
443
+ }
444
+ }
445
+ }
446
+
447
+ return Object.assign(out, observer.context());
448
+ };
449
+ }
450
+
451
+ return { loggerOpts, features, redactor, silent, isDev, observer };
452
+ }
453
+
454
+ function createLogger(opts = {}) {
455
+ const pino = require('pino');
456
+ const path = require('path');
457
+ const { loggerOpts, features, redactor, silent, isDev, observer } = buildPinoOptions(opts);
458
+
459
+ // --- destination / transport -------------------------------------------
460
+ // `opts.destination` and `transport` are mutually exclusive in Pino, so an
461
+ // explicit destination always wins and the NTL formatter is skipped.
462
+ const destination = opts.destination !== undefined && opts.destination !== null ? opts.destination : null;
463
+
464
+ if (!silent && !destination && isDev) {
26
465
  loggerOpts.transport = {
27
466
  target: path.join(__dirname, 'transports', 'pino.js'),
28
467
  options: {
@@ -32,5 +471,88 @@ module.exports.createLogger = function (opts = {}) {
32
471
  };
33
472
  }
34
473
 
35
- return pino(loggerOpts);
36
- };
474
+ const logger = decorateLogger(destination ? pino(loggerOpts, destination) : pino(loggerOpts), features, redactor, observer);
475
+
476
+ if (opts.processHandlers) {
477
+ const handlerOpts = typeof opts.processHandlers === 'object' ? opts.processHandlers : {};
478
+ Object.defineProperty(logger, 'uninstallProcessHandlers', {
479
+ value: installProcessHandlers(logger, handlerOpts),
480
+ writable: true,
481
+ configurable: true,
482
+ enumerable: false,
483
+ });
484
+ }
485
+
486
+ return logger;
487
+ }
488
+
489
+ module.exports.createLogger = createLogger;
490
+ module.exports.isSilent = isSilent;
491
+
492
+ /**
493
+ * Plain Pino options for framework-owned loggers. No transport, process
494
+ * handlers, or feature timers are created; the framework owns the lifecycle.
495
+ * Supply custom serializers here so their output is redacted too.
496
+ */
497
+ function createPinoOptions(opts = {}) {
498
+ for (const key of ['sampling', 'rateLimit', 'deduplication', 'destination', 'processHandlers']) {
499
+ if (opts[key] !== undefined) {
500
+ throw new TypeError(`createPinoOptions does not support ${key}; use createLogger instead`);
501
+ }
502
+ }
503
+ const pino = require('pino');
504
+ const { loggerOpts, redactor } = buildPinoOptions(opts);
505
+ const serializers = {
506
+ req: req => ({
507
+ method: req.method,
508
+ url: req.url,
509
+ version: req.headers && req.headers['accept-version'],
510
+ host: req.host,
511
+ remoteAddress: req.ip || (req.socket && req.socket.remoteAddress),
512
+ remotePort: req.socket && req.socket.remotePort,
513
+ }),
514
+ res: res => ({ statusCode: res.statusCode }),
515
+ err: pino.stdSerializers.err,
516
+ ...opts.serializers,
517
+ };
518
+ loggerOpts.serializers = {};
519
+ for (const [key, serialize] of Object.entries(serializers)) {
520
+ if (typeof serialize !== 'function') throw new TypeError(`Invalid serializer: ${key}`);
521
+ loggerOpts.serializers[key] = redactor
522
+ ? value => redactor.redactObject(serialize(value))
523
+ : serialize;
524
+ }
525
+ if (redactor) {
526
+ // Pino invokes formatters before serializers. Preserve their input objects
527
+ // (including prototypes/getters) and redact the serialized output instead.
528
+ loggerOpts.formatters.log = obj => {
529
+ const plain = {};
530
+ for (const key of Object.keys(obj)) {
531
+ if (!Object.hasOwn(serializers, key)) plain[key] = obj[key];
532
+ }
533
+ const safe = redactor.redactObject(plain);
534
+ for (const key of Object.keys(obj)) {
535
+ if (Object.hasOwn(serializers, key)) safe[key] = obj[key];
536
+ }
537
+ return safe;
538
+ };
539
+ }
540
+ return loggerOpts;
541
+ }
542
+
543
+ /** Capture the actual JSON records without a console or worker transport. */
544
+ function createTestLogger(opts = {}) {
545
+ const records = [];
546
+ const logger = createLogger({
547
+ ...opts,
548
+ silent: opts.silent ?? false,
549
+ level: opts.level ?? 'trace',
550
+ destination: { write(line) { records.push(JSON.parse(line)); } },
551
+ });
552
+ return { logger, records };
553
+ }
554
+
555
+ module.exports.createPinoOptions = createPinoOptions;
556
+ module.exports.createTestLogger = createTestLogger;
557
+
558
+ module.exports.withSecretValues = require('./lib/secretScope').withSecretValues;