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.
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ const { AsyncLocalStorage } = require('node:async_hooks');
3
+ const { withTimeout } = require('./lifecycle');
4
+
5
+ // Counts represent serialized records, not confirmed delivery to any destination.
6
+ function createLogObserver({ onLog, otel, onLogMaxPending = 100 } = {}) {
7
+ if (onLog !== undefined && typeof onLog !== 'function') throw new TypeError('onLog must be a function');
8
+ if (!Number.isSafeInteger(onLogMaxPending) || onLogMaxPending < 0) throw new TypeError('onLogMaxPending must be a nonnegative safe integer');
9
+ const scope = new AsyncLocalStorage();
10
+ const pending = new Set();
11
+ const counts = Object.fromEntries(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'internal'].map(k => [k, 0]));
12
+ let hookErrors = 0;
13
+ let dropped = 0;
14
+ const bridge = otel ? require('./pinoOtel').createOtelBridge(otel) : null;
15
+ const levelNames = { 10: 'trace', 20: 'debug', 30: 'info', 40: 'warn', 50: 'error', 60: 'fatal' };
16
+ function failure() { hookErrors++; }
17
+ function observe(record) {
18
+ const level = typeof record.level === 'number' ? levelNames[record.level] : record.level;
19
+ if (Object.hasOwn(counts, level)) counts[level]++;
20
+ // Suppress observers for logs made by an observer, including async descendants.
21
+ if (scope.getStore()) return;
22
+ scope.run(true, () => {
23
+ if (bridge) {
24
+ try { bridge.emit(record); } catch (_) { failure(); }
25
+ }
26
+ if (onLog) {
27
+ // In-flight promises cannot be cancelled: drop the newest callback only.
28
+ if (pending.size >= onLogMaxPending) { dropped++; return; }
29
+ try {
30
+ // Mutation of the callback record cannot change output or OTel data.
31
+ const result = onLog(JSON.parse(JSON.stringify(record)));
32
+ if (result && typeof result.then === 'function') {
33
+ const task = Promise.resolve(result).catch(failure);
34
+ pending.add(task);
35
+ task.finally(() => pending.delete(task));
36
+ }
37
+ } catch (_) { failure(); }
38
+ }
39
+ });
40
+ }
41
+ return {
42
+ context: () => bridge ? bridge.context() : {},
43
+ observe,
44
+ streamWrite(line) { observe(JSON.parse(line)); return line; },
45
+ stats: () => ({ levels: { ...counts }, hookErrors, onLog: { pending: pending.size, dropped, limit: onLogMaxPending } }),
46
+ reset() { for (const key of Object.keys(counts)) counts[key] = 0; hookErrors = 0; dropped = 0; },
47
+ async flush(timeout = 5000) {
48
+ await withTimeout((async () => {
49
+ while (pending.size) await Promise.all([...pending]);
50
+ if (bridge) await bridge.flush();
51
+ })(), timeout, 'Log observers flush');
52
+ },
53
+ };
54
+ }
55
+ module.exports = { createLogObserver };
package/lib/logger.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  const winston = require('winston');
13
13
  const crypto = require('crypto');
14
+ const { withTimeout, drain, trackClose } = require('./lifecycle');
14
15
 
15
16
  const { setupSignalHandlers } = require('./signalHandler');
16
17
  const { initPlugins } = require('../plugins/index');
@@ -26,8 +27,7 @@ const levels = require('./levels');
26
27
  // Define a map to hold the logger instances by their location
27
28
  const loggerInstances = new Map();
28
29
 
29
- // Call the signal handler setup function
30
- setupSignalHandlers(loggerInstances);
30
+ const activeLoggers = new Set();
31
31
 
32
32
  const customSettings = {
33
33
  levels: levels,
@@ -116,7 +116,7 @@ const consoleFormatter = (config) => winston.format.combine(
116
116
 
117
117
  // Apply the color to the padded level and the session ID
118
118
  // Note: The session ID color remains based on the 'color' variable (pre-calculated)
119
- return `${timestamp} [${levelColor}${paddedLevel}${resetCode}] [${colorAnsiCode}ID: ${shortSessionId}${resetCode}] [${locationStr}]: ${levelColor}${message}${resetCode}`;
119
+ return `${config.timestamp === false ? "" : timestamp + " "}[${levelColor}${paddedLevel}${resetCode}] [${colorAnsiCode}ID: ${shortSessionId}${resetCode}] [${locationStr}]: ${levelColor}${message}${resetCode}`;
120
120
  }),
121
121
  );
122
122
 
@@ -136,7 +136,7 @@ const fileFormatter = (config) => winston.format.combine(
136
136
 
137
137
  // Include all metadata in JSON (filePath is already shown in locationStr but kept in JSON for structured data)
138
138
  const metaKeys = Object.keys(meta);
139
- return `${timestamp} [${paddedLevel}] [ID: ${sessionId}] [${locationStr}]: ${message}${
139
+ return `${config.timestamp === false ? "" : timestamp + " "}[${paddedLevel}] [ID: ${sessionId}] [${locationStr}]: ${message}${
140
140
  metaKeys.length ? ' ' + JSON.stringify(meta, null, 2) : ''
141
141
  }`;
142
142
  }),
@@ -157,15 +157,20 @@ function wrapLoggerMethod(originalMethod, options = {}) {
157
157
  childContext = null,
158
158
  loggerInstance = null,
159
159
  level = 'info',
160
+ state,
160
161
  } = options;
161
162
 
162
163
  return function(...args) {
164
+ const target = loggerInstance || this;
165
+ if (state.closing) throw new Error('Logger is closed');
166
+ if (!target.isLevelEnabled(level) || target.transports.length === 0) return;
167
+ const eventTimestamp = new Date().toISOString();
163
168
  // Capture stack trace BEFORE setImmediate to get the actual call site
164
169
  let capturedCallSite = null;
165
170
  let capturedCallerOfCaller = null;
166
171
  if (reportPathEnabled) {
172
+ const originalPrepareStackTrace = Error.prepareStackTrace;
167
173
  try {
168
- const originalPrepareStackTrace = Error.prepareStackTrace;
169
174
  Error.prepareStackTrace = (_, stack) => stack;
170
175
 
171
176
  const err = new Error();
@@ -215,28 +220,30 @@ function wrapLoggerMethod(originalMethod, options = {}) {
215
220
  }
216
221
  }
217
222
  } catch (err) {
218
- // Silently fail
223
+ // Path reporting is optional.
224
+ } finally {
225
+ Error.prepareStackTrace = originalPrepareStackTrace;
219
226
  }
220
227
  }
221
228
 
222
- // Use setImmediate to make this non-blocking
229
+ state.pending++;
223
230
  setImmediate(() => {
224
231
  const startTime = performanceTracker?.enabled ? performance.now() : null;
225
232
 
226
233
  try {
227
234
  // Extract message and metadata (Winston format: method(message, meta) or method(message))
228
235
  let message = args[0];
229
- let meta = {};
236
+ let meta = { timestamp: eventTimestamp };
230
237
 
231
238
  if (args.length > 1 && typeof args[args.length - 1] === 'object' && args[args.length - 1] !== null && !Array.isArray(args[args.length - 1])) {
232
239
  // Standard format: method(message, meta)
233
- meta = { ...args[args.length - 1] };
240
+ meta = { ...args[args.length - 1], timestamp: eventTimestamp };
234
241
  message = args[0];
235
242
  } else if (args.length === 1) {
236
243
  if (typeof args[0] === 'object' && args[0] !== null && !Array.isArray(args[0])) {
237
244
  // Object format: method({ message: '...', ...other })
238
- message = args[0].message || JSON.stringify(args[0]);
239
- meta = { ...args[0] };
245
+ message = args[0].message ?? JSON.stringify(args[0]);
246
+ meta = { ...args[0], timestamp: eventTimestamp };
240
247
  delete meta.message;
241
248
  } else {
242
249
  // Just a message string
@@ -244,6 +251,10 @@ function wrapLoggerMethod(originalMethod, options = {}) {
244
251
  }
245
252
  }
246
253
 
254
+ if (args[0] instanceof Error) {
255
+ message = args[0].message;
256
+ meta = { ...meta, name: args[0].name, stack: args[0].stack, cause: args[0].cause };
257
+ }
247
258
  // Merge child context if available
248
259
  if (childContext) {
249
260
  meta = { ...childContext, ...meta };
@@ -303,7 +314,10 @@ function wrapLoggerMethod(originalMethod, options = {}) {
303
314
  // Last resort - log to console
304
315
  console.error('Logger wrapper error:', err);
305
316
  console.error('Fallback also failed:', fallbackErr);
317
+ state.error = fallbackErr;
306
318
  }
319
+ } finally {
320
+ state.pending--;
307
321
  }
308
322
  });
309
323
  };
@@ -330,6 +344,8 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
330
344
  statsInterval = 0,
331
345
  } = config;
332
346
 
347
+ const observer = require('./logObserver').createLogObserver(config);
348
+ const redactor = config.redact === false ? null : require('./secretRedaction').createRedactor(config.redact);
333
349
  let logTransport = [].filter(Boolean);
334
350
 
335
351
  if (console) {
@@ -352,7 +368,7 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
352
368
  logTransport.push(
353
369
  new winston.transports.File({ filename: `${path}/fatal.log`, level: 'fatal', format: fileFormat, maxsize: maxSize, maxFiles: maxFiles }),
354
370
  new winston.transports.File({ filename: `${path}/error.log`, level: 'error', format: fileFormat, maxsize: maxSize, maxFiles: maxFiles }),
355
- new winston.transports.File({ filename: `${path}/combined.log`, format: fileFormat, maxsize: maxSize, maxFiles: maxFiles }),
371
+ new winston.transports.File({ filename: `${path}/${config.filename || "combined.log"}`, format: fileFormat, maxsize: maxSize, maxFiles: maxFiles }),
356
372
  );
357
373
  }
358
374
  if (transports) {logTransport = logTransport.concat
@@ -362,6 +378,13 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
362
378
  const logger = winston.createLogger({
363
379
  level: level,
364
380
  levels: customSettings.levels,
381
+ format: winston.format(info => {
382
+ const safe = redactor ? redactor.redactObject(info) : { ...info };
383
+ Object.assign(safe, observer.context());
384
+ Object.assign(info, safe);
385
+ observer.observe(safe);
386
+ return info;
387
+ })(),
365
388
  transports: logTransport,
366
389
  defaultMeta: {
367
390
  location,
@@ -370,6 +393,12 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
370
393
  }
371
394
  });
372
395
 
396
+ const state = { pending: 0, closing: false, error: null };
397
+ logger.on('error', error => { state.error = error; });
398
+ logger.transports.forEach(trackClose);
399
+ const add = logger.add.bind(logger);
400
+ logger.add = transport => { trackClose(transport); return add(transport); };
401
+
373
402
  // Initialize feature modules
374
403
  const sampler = (Object.keys(sampling).length > 0 || Object.keys(rateLimit).length > 0)
375
404
  ? new LogSampler({ sampling, rateLimit })
@@ -392,6 +421,7 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
392
421
  performanceTracker: perfTracker,
393
422
  childContext,
394
423
  loggerInstance: logger,
424
+ state,
395
425
  };
396
426
 
397
427
  const boundLogger = logger;
@@ -403,15 +433,30 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
403
433
  logger.fatal = wrapLoggerMethod(boundLogger.fatal.bind(boundLogger), { ...wrapOptions, level: 'fatal' });
404
434
  logger.internal = wrapLoggerMethod(boundLogger.internal.bind(boundLogger), { ...wrapOptions, level: 'internal' });
405
435
 
406
- // Add child logger method
407
- logger.child = function(context) {
408
- const mergedContext = childContext ? { ...childContext, ...context } : context;
409
- // Use skipCache for child loggers to avoid cache conflicts
410
- const childConfig = { ...config, skipCache: true };
411
- // Generate unique location for child logger
412
- const childLocation = `${location}:child:${Date.now()}:${Math.random().toString(36).substring(7)}`;
413
- return createLoggerInstance(childLocation, childConfig, transports, mergedContext);
436
+ // Children share the root stream and transports, but never own their shutdown.
437
+ const makeChild = (context = {}) => {
438
+ const child = Object.create(logger);
439
+ const frozen = Object.freeze({ ...context });
440
+ let closed = false;
441
+ for (const name of Object.keys(levels)) {
442
+ child[name] = (...args) => {
443
+ if (closed) throw new Error('Child logger is closed');
444
+ let message = args[0];
445
+ let meta = { ...frozen, ...(args[1] || {}) };
446
+ if (message && typeof message === 'object' && !(message instanceof Error)) {
447
+ meta = { ...frozen, ...message };
448
+ message = message.message ?? JSON.stringify(message);
449
+ delete meta.message;
450
+ }
451
+ return logger[name](message, meta);
452
+ };
453
+ }
454
+ child.child = extra => makeChild({ ...frozen, ...extra });
455
+ child.flush = () => logger.flush();
456
+ child.close = () => { closed = true; return logger.flush(); };
457
+ return child;
414
458
  };
459
+ logger.child = makeChild;
415
460
 
416
461
  // Add performance tracking methods
417
462
  if (perfTracker) {
@@ -433,52 +478,64 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
433
478
  sampling: sampler ? sampler.getStats() : null,
434
479
  deduplication: deduplicator ? deduplicator.getStats() : null,
435
480
  performance: perfTracker ? perfTracker.getStats() : null,
481
+ ...observer.stats(),
436
482
  };
437
483
  return stats;
438
484
  };
439
485
 
440
- // Add flush method (non-blocking, returns Promise)
441
- logger.flush = function() {
442
- return new Promise((resolve) => {
443
- setImmediate(() => {
444
- // Flush all transports that support it
445
- const flushPromises = logger.transports.map(transport => {
446
- if (transport.flush && typeof transport.flush === 'function') {
447
- return Promise.resolve(transport.flush());
448
- }
449
- return Promise.resolve();
450
- });
451
- Promise.all(flushPromises).then(() => resolve());
452
- });
453
- });
486
+ logger.resetStats = () => {
487
+ sampler?.resetStats();
488
+ deduplicator?.resetStats();
489
+ observer.reset();
454
490
  };
455
491
 
456
- // Add close method (graceful shutdown)
457
- logger.close = function() {
458
- return new Promise((resolve) => {
459
- setImmediate(() => {
460
- // Destroy feature modules
461
- if (sampler) sampler.destroy();
462
- if (deduplicator) deduplicator.destroy();
463
-
464
- // Close all transports
465
- logger.end(() => {
466
- resolve();
467
- });
468
- });
492
+ const timeout = config.shutdownTimeout ?? 30000;
493
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Invalid shutdownTimeout');
494
+ let closePromise;
495
+ let statsTimer;
496
+ logger.flush = () => withTimeout((async () => {
497
+ await drain(logger, state, timeout);
498
+ await Promise.all(logger.transports.map(t => t.flush?.()));
499
+ await observer.flush(timeout);
500
+ await drain(logger, state, timeout);
501
+ })(), timeout, 'Logger flush');
502
+
503
+ logger.close = () => {
504
+ if (closePromise) return closePromise;
505
+ state.closing = true;
506
+ clearInterval(statsTimer);
507
+ if (loggerInstances.get(location) === logger) loggerInstances.delete(location);
508
+ const owned = logger.transports.slice();
509
+ closePromise = withTimeout((async () => {
510
+ let failure;
511
+ try { await logger.flush(); } catch (error) { failure = error; }
512
+ try {
513
+ await withTimeout(new Promise((resolve, reject) => {
514
+ if (logger._writableState.finished) return resolve();
515
+ const onError = error => { logger.removeListener('finish', onFinish); reject(error); };
516
+ const onFinish = () => { logger.removeListener('error', onError); resolve(); };
517
+ logger.once('error', onError);
518
+ logger.once('finish', onFinish);
519
+ logger.end();
520
+ }), timeout, 'Logger stream close');
521
+ } catch (error) { failure ||= error; }
522
+ const results = await Promise.allSettled(owned.map(t => t._ntlClose()));
523
+ failure ||= results.find(result => result.status === 'rejected')?.reason;
524
+ if (failure) throw failure;
525
+ })(), timeout * 2, 'Logger close').finally(() => {
526
+ if (sampler) sampler.destroy();
527
+ if (deduplicator) deduplicator.destroy();
528
+ activeLoggers.delete(logger);
469
529
  });
530
+ return closePromise;
470
531
  };
471
532
 
472
- // Statistics reporting interval
473
533
  if (statsInterval > 0 && (sampler || deduplicator)) {
474
- setInterval(() => {
475
- const stats = logger.getStats();
476
- logger.internal('Logger Statistics:', stats);
477
- }, statsInterval);
534
+ statsTimer = setInterval(() => logger.internal('Logger Statistics:', logger.getStats()), statsInterval);
535
+ statsTimer.unref();
478
536
  }
479
-
480
- // Cache the newly created logger instance
481
- loggerInstances.set(location, logger);
537
+ if (!skipCache) loggerInstances.set(location, logger);
538
+ activeLoggers.add(logger);
482
539
 
483
540
  if (debug === true) {
484
541
  // Use pre-calculated color values
@@ -524,6 +581,7 @@ const logger = (location = "Unknown", config = {}) => {
524
581
  // If cached logger doesn't have the features but config requests them, recreate
525
582
  if (!cachedHasFeatures) {
526
583
  // Remove from cache and recreate with new config
584
+ cachedLogger.close().catch(error => global.console.error('Logger replacement cleanup failed:', error));
527
585
  loggerInstances.delete(location);
528
586
  } else {
529
587
  // Cached logger has features, return it
@@ -547,5 +605,7 @@ const logger = (location = "Unknown", config = {}) => {
547
605
  return createLoggerInstance(location, config, transports);
548
606
  };
549
607
 
608
+ logger.setupSignalHandlers = options => setupSignalHandlers(activeLoggers, options);
609
+
550
610
  // Export a function to create or return the existing logger instance
551
611
  module.exports = logger;