@zenvia/logger 2.0.0 → 2.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenvia/logger",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "A wrapper for Winston Logging Node.js library that formats the output on STDOUT as Logstash JSON format.",
5
5
  "license": "MIT",
6
6
  "main": "./src/index",
@@ -20,7 +20,7 @@
20
20
  "homepage": "https://github.com/zenvia/zenvia-logger-node#readme",
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git@github.com:zenvia/zenvia-logger-node.git"
23
+ "url": "git+ssh://git@github.com/zenvia/zenvia-logger-node.git"
24
24
  },
25
25
  "bugs": {
26
26
  "url": "https://github.com/zenvia/zenvia-logger-node/issues"
package/src/index.d.ts CHANGED
@@ -2,10 +2,9 @@
2
2
  import { ZenviaLogger } from './lib/logger';
3
3
  import traceMiddlewareFactory from './middleware/trace';
4
4
 
5
- type TraceMiddlewareInstance = ReturnType<typeof traceMiddlewareFactory>;
6
5
  type ZenviaLoggerModule = ZenviaLogger & {
7
6
  default: ZenviaLogger;
8
- traceMiddleware: TraceMiddlewareInstance;
7
+ traceMiddleware: typeof traceMiddlewareFactory;
9
8
  };
10
9
 
11
10
  declare const loggerModule: ZenviaLoggerModule;
package/src/lib/logger.js CHANGED
@@ -29,7 +29,9 @@ const customFormatJson = winston.format((info) => {
29
29
  environment: process.env.NODE_ENV,
30
30
  host: process.env.HOST || process.env.HOSTNAME,
31
31
  message: info.message || '',
32
- level: ['verbose', 'silly'].includes(info.level) ? 'DEBUG' : info.level.toUpperCase(),
32
+ level: ['verbose', 'silly'].includes(info.level)
33
+ ? 'DEBUG'
34
+ : info.level.toUpperCase(),
33
35
  stack_trace: stack,
34
36
  traceId: rTrace.id(),
35
37
  };
@@ -51,7 +53,9 @@ const customCombineJson = winston.format.combine(
51
53
 
52
54
  const customCombineSimple = winston.format.combine(
53
55
  winston.format.timestamp(),
54
- winston.format.printf((info) => (`${info.timestamp} - ${info.level}: ${info.message}`)),
56
+ winston.format.printf(
57
+ (info) => `${info.timestamp} - ${info.level}: ${info.message}`,
58
+ ),
55
59
  );
56
60
 
57
61
  const createConsoleTransport = () => new winston.transports.Console({
@@ -70,75 +74,234 @@ const baseLogger = winston.createLogger({
70
74
  },
71
75
  level: process.env.LOGGING_LEVEL || 'debug',
72
76
  handleExceptions: true,
73
- format: process.env.LOGGING_FORMATTER_DISABLED && process.env.LOGGING_FORMATTER_DISABLED === 'true' ? customCombineSimple : customCombineJson,
74
- transports: [
75
- createConsoleTransport(),
76
- ],
77
+ format:
78
+ process.env.LOGGING_FORMATTER_DISABLED
79
+ && process.env.LOGGING_FORMATTER_DISABLED === 'true'
80
+ ? customCombineSimple
81
+ : customCombineJson,
82
+ transports: [createConsoleTransport()],
77
83
  exitOnError: false,
78
84
  });
79
85
 
80
- function getActiveLogger() {
81
- const store = loggerStorage.getStore();
82
- return store ? store.current : baseLogger;
86
+ // HYBRID APPROACH: Monkey-Patching with Structured Functions
87
+ //
88
+ // Architecture: We use monkey-patching for backward compatibility (ensures
89
+ // `logger instanceof winston.Logger` === true), but extract all logic into
90
+ // isolated, testable functions (Wrapper pattern structure).
91
+ //
92
+ // This provides:
93
+ // - Full backward compatibility (exports real Winston instance)
94
+ // - High maintainability (isolated, testable functions)
95
+ // - Easy future migration to pure Wrapper pattern in major versions
96
+
97
+ // Preserve original log method before patching
98
+ const originalLog = baseLogger.log.bind(baseLogger);
99
+
100
+ /**
101
+ * Determines if a log call should be silenced based on arguments.
102
+ *
103
+ * @param {Array} args - The arguments passed to the log function
104
+ * @returns {boolean} - true if should log, false if should silence
105
+ */
106
+ function shouldLog(args) {
107
+ // Case 1: No arguments - silence
108
+ if (args.length === 0) {
109
+ return false;
110
+ }
111
+
112
+ // Case 2: Single string argument that's just a level - silence
113
+ if (
114
+ args.length === 1
115
+ && typeof args[0] === 'string'
116
+ && !args[0].includes(' ')
117
+ ) {
118
+ const possibleLevel = args[0].toLowerCase();
119
+ if (
120
+ ['fatal', 'error', 'warn', 'info', 'debug', 'verbose', 'silly'].includes(
121
+ possibleLevel,
122
+ )
123
+ ) {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ // Case 3: logger.log('info') - only level without message - silence
129
+ if (args.length === 1 && typeof args[0] === 'string') {
130
+ return false;
131
+ }
132
+
133
+ return true;
83
134
  }
84
135
 
85
- function leveledLogFn(level) {
86
- return function log(...args) {
87
- return logFn(level, ...args);
88
- };
136
+ /**
137
+ * Deep clones an object to prevent mutation.
138
+ * Special handling for Error objects to preserve Winston's concatenation behavior.
139
+ *
140
+ * @param {*} obj - The object to clone
141
+ * @returns {*} - The cloned object
142
+ */
143
+ function deepClone(obj) {
144
+ if (obj === null || typeof obj !== 'object') {
145
+ return obj;
146
+ }
147
+
148
+ if (obj instanceof Error) {
149
+ // Create new Error instance to preserve Winston's error handling
150
+ // (Winston concatenates message with error string)
151
+ const cloned = new Error(obj.message);
152
+ cloned.stack = obj.stack;
153
+
154
+ // Copy additional properties
155
+ Object.keys(obj).forEach((key) => {
156
+ if (key !== 'message' && key !== 'stack') {
157
+ cloned[key] = obj[key];
158
+ }
159
+ });
160
+ return cloned;
161
+ }
162
+
163
+ if (Array.isArray(obj)) {
164
+ return obj.map(deepClone);
165
+ }
166
+
167
+ const cloned = {};
168
+ Object.keys(obj).forEach((key) => {
169
+ cloned[key] = deepClone(obj[key]);
170
+ });
171
+ return cloned;
89
172
  }
90
173
 
91
- function logFn(level, msg) {
92
- const currentLogger = getActiveLogger();
93
- if (arguments.length === 1 && typeof level !== 'object') {
94
- return currentLogger;
174
+ /**
175
+ * Injects AsyncLocalStorage context metadata into log arguments.
176
+ *
177
+ * @param {Array} args - The log arguments
178
+ * @returns {Array} - Arguments with context injected
179
+ */
180
+ function injectContext(args) {
181
+ const store = loggerStorage.getStore();
182
+ if (!store || !store.context) {
183
+ return args;
95
184
  }
96
- if (arguments.length === 2) {
97
- if (msg && typeof msg === 'object') {
98
- msg = {
99
- message: msg.message,
100
- stack: msg.stack,
101
- ...msg,
102
- };
185
+
186
+ const newArgs = Array.from(args);
187
+ let metaIndex = -1;
188
+
189
+ // Determine where metadata object is in arguments
190
+ if (newArgs.length === 1) {
191
+ // logger.info(obj) or logger.info(error)
192
+ if (typeof newArgs[0] === 'object') {
193
+ metaIndex = 0;
194
+ }
195
+ } else if (newArgs.length === 2) {
196
+ // logger.info('msg', obj) or logger.log('level', obj)
197
+ if (typeof newArgs[1] === 'object') {
198
+ metaIndex = 1;
103
199
  }
200
+ } else {
201
+ // logger.log('level', 'msg', obj)
202
+ metaIndex = 2;
104
203
  }
105
- return currentLogger.log(...arguments);
106
- }
107
204
 
108
- function isLevelEnabledFn(level) {
109
- return function isLevelEnabled() {
110
- return getActiveLogger().isLevelEnabled(level);
111
- };
205
+ if (metaIndex >= 0 && newArgs[metaIndex]) {
206
+ // Merge context into existing metadata object
207
+ newArgs[metaIndex] = {
208
+ ...store.context,
209
+ ...newArgs[metaIndex],
210
+ };
211
+ } else {
212
+ // Add context as new argument
213
+ newArgs.push(store.context);
214
+ }
215
+
216
+ return newArgs;
112
217
  }
113
218
 
114
- const logger = {
115
- ...baseLogger,
116
-
117
- fatal: leveledLogFn('fatal'),
118
- error: leveledLogFn('error'),
119
- warn: leveledLogFn('warn'),
120
- info: leveledLogFn('info'),
121
- debug: leveledLogFn('debug'),
122
- verbose: leveledLogFn('verbose'),
123
- silly: leveledLogFn('silly'),
124
- log: logFn,
125
- isFatalEnabled: isLevelEnabledFn('fatal'),
126
-
127
- runWithContext: (context, fn) => {
128
- const parentLogger = loggerStorage.getStore()?.current || baseLogger;
129
- const child = parentLogger.child(context);
130
- return loggerStorage.run({ current: child }, fn);
131
- },
219
+ /**
220
+ * Controlled log function with validation, cloning, and context injection.
221
+ * This is the core logic that all log methods delegate to.
222
+ *
223
+ * @param {...*} args - Log arguments (level, message, metadata)
224
+ * @returns {Logger} - The logger instance for chaining
225
+ */
226
+ function controlledLog(...args) {
227
+ // Step 1: Validate - should we log?
228
+ if (!shouldLog(args)) {
229
+ return this;
230
+ }
132
231
 
133
- addContext: (context) => {
134
- const store = loggerStorage.getStore();
135
- if (store) {
136
- store.current = store.current.child(context);
137
- } else {
138
- baseLogger.warn('Attempted to call addContext outside of an AsyncLocalStorage context');
232
+ // Step 2: Clone arguments to prevent mutation
233
+ let clonedArgs = args.map((arg) => {
234
+ if (typeof arg === 'object' && arg !== null) {
235
+ return deepClone(arg);
139
236
  }
237
+ return arg;
238
+ });
239
+
240
+ // Step 3: Inject AsyncLocalStorage context
241
+ clonedArgs = injectContext(clonedArgs);
242
+
243
+ // Step 4: Delegate to original Winston log
244
+ return originalLog(...clonedArgs);
245
+ }
246
+
247
+ // MONKEY-PATCHING: Apply controlled logic to Winston instance
248
+
249
+ // Override .log() method
250
+ baseLogger.log = controlledLog;
251
+
252
+ // Override convenience methods (info, error, warn, etc.)
253
+ ['fatal', 'error', 'warn', 'info', 'debug', 'verbose', 'silly'].forEach(
254
+ (level) => {
255
+ baseLogger[level] = function leveledLog(...args) {
256
+ return controlledLog.call(this, level, ...args);
257
+ };
140
258
  },
259
+ );
260
+
261
+ // CUSTOM METHODS: Context management and utilities
262
+
263
+ /**
264
+ * Check if FATAL level is enabled.
265
+ *
266
+ * @returns {boolean}
267
+ */
268
+ baseLogger.isFatalEnabled = function isFatalEnabled() {
269
+ return this.isLevelEnabled('fatal');
270
+ };
271
+
272
+ /**
273
+ * Execute a function within a logging context.
274
+ * Context metadata is automatically injected into all logs within the function.
275
+ *
276
+ * @param {Object} context - Metadata to inject (e.g., { requestId: '123' })
277
+ * @param {Function} fn - Function to execute within context
278
+ * @returns {*} - Return value of fn
279
+ */
280
+ baseLogger.runWithContext = function runWithContext(context, fn) {
281
+ const parentStore = loggerStorage.getStore();
282
+ const mergedContext = parentStore && parentStore.context
283
+ ? { ...parentStore.context, ...context }
284
+ : context;
285
+
286
+ return loggerStorage.run({ context: mergedContext }, fn);
287
+ };
288
+
289
+ /**
290
+ * Add metadata to the current logging context.
291
+ * This mutates the current context (useful for progressive enrichment).
292
+ *
293
+ * @param {Object} context - Additional metadata to merge
294
+ */
295
+ baseLogger.addContext = function addContext(context) {
296
+ const store = loggerStorage.getStore();
297
+ if (store) {
298
+ store.context = { ...store.context, ...context };
299
+ } else {
300
+ baseLogger.warn(
301
+ 'Attempted to call addContext outside of an AsyncLocalStorage context',
302
+ );
303
+ }
141
304
  };
142
305
 
143
- module.exports = logger;
144
- module.exports.default = logger;
306
+ module.exports = baseLogger;
307
+ module.exports.default = baseLogger;
@@ -9,7 +9,9 @@ describe('Logger test', () => {
9
9
  before(() => {
10
10
  process.env.APP_NAME = 'application-name';
11
11
  stdMocks.use({ print: true });
12
- brokenClock = sinon.useFakeTimers(new Date('2018-06-05T18:20:42.345Z').getTime());
12
+ brokenClock = sinon.useFakeTimers(
13
+ new Date('2018-06-05T18:20:42.345Z').getTime(),
14
+ );
13
15
  });
14
16
 
15
17
  beforeEach(() => {
@@ -124,10 +126,16 @@ describe('Logger test', () => {
124
126
  logger.info('some message', new Error('some reason'));
125
127
 
126
128
  const actualOutput = JSON.parse(stdMocks.flush().stdout[0]);
127
- actualOutput.should.have.property('@timestamp').and.be.equal('2018-06-05T18:20:42.345Z');
129
+ actualOutput.should.have
130
+ .property('@timestamp')
131
+ .and.be.equal('2018-06-05T18:20:42.345Z');
128
132
  actualOutput.should.have.property('@version').and.be.equal(1);
129
- actualOutput.should.have.property('application').and.be.equal('application-name');
130
- actualOutput.should.have.property('message').and.be.equal('some message some reason');
133
+ actualOutput.should.have
134
+ .property('application')
135
+ .and.be.equal('application-name');
136
+ actualOutput.should.have
137
+ .property('message')
138
+ .and.be.equal('some message some reason');
131
139
  actualOutput.should.have.property('level').and.be.equal('INFO');
132
140
  actualOutput.should.have.property('stack_trace');
133
141
  });
@@ -136,9 +144,13 @@ describe('Logger test', () => {
136
144
  logger.info(new Error('some reason'));
137
145
 
138
146
  const actualOutput = JSON.parse(stdMocks.flush().stdout[0]);
139
- actualOutput.should.have.property('@timestamp').and.be.equal('2018-06-05T18:20:42.345Z');
147
+ actualOutput.should.have
148
+ .property('@timestamp')
149
+ .and.be.equal('2018-06-05T18:20:42.345Z');
140
150
  actualOutput.should.have.property('@version').and.be.equal(1);
141
- actualOutput.should.have.property('application').and.be.equal('application-name');
151
+ actualOutput.should.have
152
+ .property('application')
153
+ .and.be.equal('application-name');
142
154
  actualOutput.should.have.property('message').and.be.equal('some reason');
143
155
  actualOutput.should.have.property('level').and.be.equal('INFO');
144
156
  actualOutput.should.have.property('stack_trace');
@@ -186,7 +198,11 @@ describe('Logger test', () => {
186
198
 
187
199
  describe('Logging level', () => {
188
200
  it('should log with LogEntry', () => {
189
- const obj = { level: 'info', message: 'some message', property: 'some value' };
201
+ const obj = {
202
+ level: 'info',
203
+ message: 'some message',
204
+ property: 'some value',
205
+ };
190
206
  logger.log(obj);
191
207
  const actualOutput = JSON.parse(stdMocks.flush().stdout[0]);
192
208
  actualOutput.should.have.property('level').and.be.equal('INFO');
@@ -245,7 +261,7 @@ describe('Logger test', () => {
245
261
  delete require.cache[require.resolve('../../src/lib/logger')];
246
262
  process.env.LOGGING_LEVEL = 'INFO';
247
263
  // eslint-disable-next-line
248
- const newLogger = require('../../src/lib/logger');
264
+ const newLogger = require("../../src/lib/logger");
249
265
  newLogger.debug('should not log this message');
250
266
  delete process.env.LOGGING_LEVEL;
251
267
  delete require.cache[require.resolve('../../src/lib/logger')];
@@ -260,13 +276,15 @@ describe('Logger test', () => {
260
276
  delete require.cache[require.resolve('../../src/lib/logger')];
261
277
  process.env.LOGGING_FORMATTER_DISABLED = 'true';
262
278
  // eslint-disable-next-line
263
- const newLogger = require('../../src/lib/logger');
279
+ const newLogger = require("../../src/lib/logger");
264
280
  newLogger.debug('some message');
265
281
  delete process.env.LOGGING_FORMATTER_DISABLED;
266
282
  delete require.cache[require.resolve('../../src/lib/logger')];
267
283
 
268
284
  const actualOutput = stdMocks.flush().stdout[0];
269
- actualOutput.should.be.equal('2018-06-05T18:20:42.345Z - debug: some message\n');
285
+ actualOutput.should.be.equal(
286
+ '2018-06-05T18:20:42.345Z - debug: some message\n',
287
+ );
270
288
  });
271
289
  });
272
290
 
@@ -302,16 +320,21 @@ describe('Logger test', () => {
302
320
 
303
321
  describe('Contextual Logging (AsyncLocalStorage)', () => {
304
322
  it('should maintain context metadata across async calls using runWithContext', async () => {
305
- await logger.runWithContext({ partition: 1024, topic: 'my-topic' }, async () => {
306
- await Promise.resolve();
323
+ await logger.runWithContext(
324
+ { partition: 1024, topic: 'my-topic' },
325
+ async () => {
326
+ await Promise.resolve();
307
327
 
308
- logger.info('message inside context');
328
+ logger.info('message inside context');
309
329
 
310
- const output = JSON.parse(stdMocks.flush().stdout[0]);
311
- output.should.have.property('partition').and.be.equal(1024);
312
- output.should.have.property('topic').and.be.equal('my-topic');
313
- output.should.have.property('message').and.be.equal('message inside context');
314
- });
330
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
331
+ output.should.have.property('partition').and.be.equal(1024);
332
+ output.should.have.property('topic').and.be.equal('my-topic');
333
+ output.should.have
334
+ .property('message')
335
+ .and.be.equal('message inside context');
336
+ },
337
+ );
315
338
  });
316
339
 
317
340
  it('should update context metadata using addContext (mutation)', async () => {
@@ -336,7 +359,11 @@ describe('Logger test', () => {
336
359
  const parsedOutput = JSON.parse(actualOutput);
337
360
 
338
361
  parsedOutput.should.have.property('level').and.be.equal('WARN');
339
- parsedOutput.should.have.property('message').and.be.equal('Attempted to call addContext outside of an AsyncLocalStorage context');
362
+ parsedOutput.should.have
363
+ .property('message')
364
+ .and.be.equal(
365
+ 'Attempted to call addContext outside of an AsyncLocalStorage context',
366
+ );
340
367
  });
341
368
 
342
369
  it('should isolate contexts between concurrent executions', async () => {
@@ -383,5 +410,343 @@ describe('Logger test', () => {
383
410
  });
384
411
  });
385
412
  });
413
+
414
+ it('should inject context when using logger.log passing a LogEntry object', async () => {
415
+ await logger.runWithContext({ requestId: 'req-100' }, async () => {
416
+ logger.log({
417
+ level: 'info',
418
+ message: 'context check',
419
+ });
420
+
421
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
422
+ output.should.have.property('requestId').and.be.equal('req-100');
423
+ output.should.have.property('message').and.be.equal('context check');
424
+ });
425
+ });
426
+ });
427
+
428
+ describe('Edge Cases and Coverage', () => {
429
+ describe('shouldLog function coverage', () => {
430
+ it('should silence log when single argument is a level without spaces', () => {
431
+ logger.log('error');
432
+ stdMocks.flush().stderr.length.should.be.equal(0);
433
+ });
434
+
435
+ it('should silence log when single argument is "fatal" level', () => {
436
+ logger.log('fatal');
437
+ stdMocks.flush().stderr.length.should.be.equal(0);
438
+ });
439
+
440
+ it('should silence log when single argument is "warn" level', () => {
441
+ logger.log('warn');
442
+ stdMocks.flush().stdout.length.should.be.equal(0);
443
+ });
444
+
445
+ it('should silence log when single argument is "debug" level', () => {
446
+ logger.log('debug');
447
+ stdMocks.flush().stdout.length.should.be.equal(0);
448
+ });
449
+
450
+ it('should silence log when single argument is "verbose" level', () => {
451
+ logger.log('verbose');
452
+ stdMocks.flush().stdout.length.should.be.equal(0);
453
+ });
454
+
455
+ it('should silence log when single argument is "silly" level', () => {
456
+ logger.log('silly');
457
+ stdMocks.flush().stdout.length.should.be.equal(0);
458
+ });
459
+
460
+ it('should silence log when called via logger.log() with no arguments', () => {
461
+ logger.log();
462
+ stdMocks.flush().stdout.length.should.be.equal(0);
463
+ stdMocks.flush().stderr.length.should.be.equal(0);
464
+ });
465
+
466
+ it('should silence log when called via logger.log() with a non-level string', () => {
467
+ logger.log('simple message without level');
468
+ stdMocks.flush().stdout.length.should.be.equal(0);
469
+ });
470
+
471
+ it('should silence log when single string argument is NOT a valid level', () => {
472
+ logger.log('NotALevelAndNoSpaces');
473
+ stdMocks.flush().stdout.length.should.be.equal(0);
474
+ });
475
+ });
476
+
477
+ describe('deepClone function coverage', () => {
478
+ it('should clone null values', () => {
479
+ logger.info('message', { nullValue: null });
480
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
481
+ output.should.have.property('nullValue').and.be.equal(null);
482
+ });
483
+
484
+ it('should clone primitive values (numbers)', () => {
485
+ logger.info('message', { count: 42 });
486
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
487
+ output.should.have.property('count').and.be.equal(42);
488
+ });
489
+
490
+ it('should clone primitive values (booleans)', () => {
491
+ logger.info('message', { active: true });
492
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
493
+ output.should.have.property('active').and.be.equal(true);
494
+ });
495
+
496
+ it('should clone arrays', () => {
497
+ const originalArray = [1, 2, 3];
498
+ logger.info('message', { items: originalArray });
499
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
500
+ output.should.have.property('items').and.deep.equal([1, 2, 3]);
501
+ });
502
+
503
+ it('should clone nested arrays', () => {
504
+ logger.info('message', {
505
+ matrix: [
506
+ [1, 2],
507
+ [3, 4],
508
+ ],
509
+ });
510
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
511
+ output.should.have.property('matrix').and.deep.equal([
512
+ [1, 2],
513
+ [3, 4],
514
+ ]);
515
+ });
516
+
517
+ it('should clone Error objects with additional properties', () => {
518
+ const error = new Error('test error');
519
+ error.code = 'ERR_TEST';
520
+ error.statusCode = 500;
521
+
522
+ logger.info('error occurred', error);
523
+
524
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
525
+ output.should.have.property('code').and.be.equal('ERR_TEST');
526
+ output.should.have.property('statusCode').and.be.equal(500);
527
+ });
528
+
529
+ it('should not mutate nested objects', () => {
530
+ const nested = { inner: { value: 'test' } };
531
+ logger.info('message', nested);
532
+ nested.should.not.have.property('level');
533
+ nested.inner.should.not.have.property('level');
534
+ });
535
+
536
+ it('should clone Error objects preventing duplication of message/stack if enumerable', () => {
537
+ const err = new Error('custom error');
538
+
539
+ Object.defineProperty(err, 'message', {
540
+ enumerable: true,
541
+ value: 'custom msg',
542
+ });
543
+ Object.defineProperty(err, 'stack', {
544
+ enumerable: true,
545
+ value: 'custom stack',
546
+ });
547
+
548
+ logger.info(err);
549
+
550
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
551
+ output.should.have.property('message').and.include('custom msg');
552
+ });
553
+ });
554
+
555
+ describe('injectContext function coverage', () => {
556
+ it('should handle single object argument with context', async () => {
557
+ stdMocks.flush(); // Clear previous outputs
558
+ await logger.runWithContext({ ctxKey: 'ctxValue' }, async () => {
559
+ logger.info({ message: 'test message', extraKey: 'extraValue' });
560
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
561
+ output.should.have.property('ctxKey').and.be.equal('ctxValue');
562
+ output.should.have.property('extraKey').and.be.equal('extraValue');
563
+ });
564
+ });
565
+
566
+ it('should handle two arguments (message + object) with context', async () => {
567
+ stdMocks.flush(); // Clear previous outputs
568
+ await logger.runWithContext({ requestId: '123' }, async () => {
569
+ logger.info('test message', { userId: 'abc' });
570
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
571
+ output.should.have.property('requestId').and.be.equal('123');
572
+ output.should.have.property('userId').and.be.equal('abc');
573
+ });
574
+ });
575
+
576
+ it('should add context as new argument when no metadata object exists', async () => {
577
+ stdMocks.flush(); // Clear previous outputs
578
+ await logger.runWithContext({ sessionId: 'session-123' }, async () => {
579
+ logger.info('just a message');
580
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
581
+ output.should.have.property('sessionId').and.be.equal('session-123');
582
+ });
583
+ });
584
+
585
+ it('should handle single Error argument with context', async () => {
586
+ stdMocks.flush(); // Clear previous outputs
587
+ await logger.runWithContext({ errorContext: 'handler-1' }, async () => {
588
+ logger.error(new Error('error message'));
589
+ const output = JSON.parse(stdMocks.flush().stderr[0]);
590
+ output.should.have.property('errorContext').and.be.equal('handler-1');
591
+ });
592
+ });
593
+
594
+ it('should handle message + error with context', async () => {
595
+ stdMocks.flush(); // Clear previous outputs
596
+ await logger.runWithContext({ errorCtx: 'value' }, async () => {
597
+ logger.error('error occurred', new Error('failure'));
598
+ const output = JSON.parse(stdMocks.flush().stderr[0]);
599
+ output.should.have.property('errorCtx').and.be.equal('value');
600
+ output.should.have.property('message').and.include('error occurred');
601
+ });
602
+ });
603
+
604
+ it('should append context as new argument when single argument is primitive', async () => {
605
+ await logger.runWithContext({ ctx: 'test-val' }, async () => {
606
+ try {
607
+ logger.log(12345);
608
+ } catch (error) {
609
+ error.message.should.match(/toUpperCase/);
610
+ }
611
+ });
612
+ });
613
+ });
614
+
615
+ describe('controlledLog edge cases', () => {
616
+ it('should handle log with object containing level property', () => {
617
+ logger.log({ level: 'info', message: 'test', customField: 'value' });
618
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
619
+ output.should.have.property('level').and.be.equal('INFO');
620
+ output.should.have.property('customField').and.be.equal('value');
621
+ });
622
+
623
+ it('should handle multiple metadata objects via spread', () => {
624
+ logger.info('message', {
625
+ key1: 'value1',
626
+ key2: 'value2',
627
+ key3: 'value3',
628
+ });
629
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
630
+ output.should.have.property('key1').and.be.equal('value1');
631
+ output.should.have.property('key2').and.be.equal('value2');
632
+ output.should.have.property('key3').and.be.equal('value3');
633
+ });
634
+ });
635
+
636
+ describe('Custom methods coverage', () => {
637
+ it('should check isFatalEnabled returns correct value', () => {
638
+ const isEnabled = logger.isFatalEnabled();
639
+ isEnabled.should.be.a('boolean');
640
+ });
641
+
642
+ it('should handle runWithContext with no parent context', async () => {
643
+ // Ensure we're outside any context
644
+ await logger.runWithContext({ newContext: 'value' }, async () => {
645
+ logger.info('test');
646
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
647
+ output.should.have.property('newContext').and.be.equal('value');
648
+ });
649
+ });
650
+
651
+ it('should handle addContext when context already exists', async () => {
652
+ await logger.runWithContext({ initial: 'context' }, async () => {
653
+ logger.addContext({ added: 'field' });
654
+ logger.addContext({ another: 'field' });
655
+
656
+ logger.info('test');
657
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
658
+ output.should.have.property('initial').and.be.equal('context');
659
+ output.should.have.property('added').and.be.equal('field');
660
+ output.should.have.property('another').and.be.equal('field');
661
+ });
662
+ });
663
+ });
664
+
665
+ describe('All log levels coverage', () => {
666
+ it('should log fatal level with metadata', () => {
667
+ logger.fatal('fatal message', { fatalMeta: 'value' });
668
+ const output = JSON.parse(stdMocks.flush().stderr[0]);
669
+ output.should.have.property('level').and.be.equal('FATAL');
670
+ output.should.have.property('fatalMeta').and.be.equal('value');
671
+ });
672
+
673
+ it('should log error level with metadata', () => {
674
+ logger.error('error message', { errorMeta: 'value' });
675
+ const output = JSON.parse(stdMocks.flush().stderr[0]);
676
+ output.should.have.property('level').and.be.equal('ERROR');
677
+ output.should.have.property('errorMeta').and.be.equal('value');
678
+ });
679
+
680
+ it('should log warn level with metadata', () => {
681
+ logger.warn('warn message', { warnMeta: 'value' });
682
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
683
+ output.should.have.property('level').and.be.equal('WARN');
684
+ output.should.have.property('warnMeta').and.be.equal('value');
685
+ });
686
+
687
+ it('should log verbose level with metadata', () => {
688
+ logger.verbose('verbose message', { verboseMeta: 'value' });
689
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
690
+ output.should.have.property('level').and.be.equal('DEBUG');
691
+ output.should.have.property('verboseMeta').and.be.equal('value');
692
+ });
693
+
694
+ it('should log silly level with metadata', () => {
695
+ logger.silly('silly message', { sillyMeta: 'value' });
696
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
697
+ output.should.have.property('level').and.be.equal('DEBUG');
698
+ output.should.have.property('sillyMeta').and.be.equal('value');
699
+ });
700
+ });
701
+
702
+ describe('Complex scenarios', () => {
703
+ it('should handle deeply nested contexts', async () => {
704
+ await logger.runWithContext({ level1: 'a' }, async () => {
705
+ await logger.runWithContext({ level2: 'b' }, async () => {
706
+ await logger.runWithContext({ level3: 'c' }, async () => {
707
+ logger.info('deeply nested');
708
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
709
+ output.should.have.property('level1').and.be.equal('a');
710
+ output.should.have.property('level2').and.be.equal('b');
711
+ output.should.have.property('level3').and.be.equal('c');
712
+ });
713
+ });
714
+ });
715
+ });
716
+
717
+ it('should handle context with addContext in nested scenarios', async () => {
718
+ await logger.runWithContext({ outer: 'context' }, async () => {
719
+ logger.addContext({ outerAdded: 'field1' });
720
+
721
+ await logger.runWithContext({ inner: 'context' }, async () => {
722
+ logger.addContext({ innerAdded: 'field2' });
723
+
724
+ logger.info('nested with additions');
725
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
726
+ output.should.have.property('outer').and.be.equal('context');
727
+ output.should.have.property('inner').and.be.equal('context');
728
+ output.should.have.property('innerAdded').and.be.equal('field2');
729
+ });
730
+ });
731
+ });
732
+
733
+ it('should handle logging with null metadata object', () => {
734
+ logger.info('message', null);
735
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
736
+ output.should.have.property('message').and.be.equal('message');
737
+ });
738
+
739
+ it('should handle logging with undefined metadata', () => {
740
+ logger.info('message', undefined);
741
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
742
+ output.should.have.property('message').and.be.equal('message');
743
+ });
744
+
745
+ it('should properly clone objects with circular references removed', () => {
746
+ const obj = { message: 'test', regular: 'field' };
747
+ logger.info(obj);
748
+ obj.should.not.have.property('level');
749
+ });
750
+ });
386
751
  });
387
752
  });