@zenvia/logger 1.6.1 → 2.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.
@@ -11,10 +11,10 @@ jobs:
11
11
  build:
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: actions/checkout@v3
15
- - uses: actions/setup-node@v3
14
+ - uses: actions/checkout@v6
15
+ - uses: actions/setup-node@v6
16
16
  with:
17
- node-version: 18
17
+ node-version: '24'
18
18
  - name: Install dependencies
19
19
  run: npm ci
20
20
  - name: Run lint
@@ -30,13 +30,14 @@ jobs:
30
30
  publish-npm:
31
31
  needs: build
32
32
  runs-on: ubuntu-latest
33
+ permissions:
34
+ id-token: write
35
+ contents: read
33
36
  steps:
34
- - uses: actions/checkout@v3
35
- - uses: actions/setup-node@v3
37
+ - uses: actions/checkout@v6
38
+ - uses: actions/setup-node@v6
36
39
  with:
37
- node-version: 18
38
- registry-url: https://registry.npmjs.org/
40
+ node-version: '24'
41
+ registry-url: 'https://registry.npmjs.org'
39
42
  - run: npm ci
40
- - run: npm publish
41
- env:
42
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
43
+ - run: NODE_AUTH_TOKEN="" npm publish --provenance
package/README.md CHANGED
@@ -1,14 +1,19 @@
1
1
  # Zenvia Logger for Node.js
2
2
 
3
- A wrapper for [Winston](https://github.com/winstonjs/winston) Logging [Node.js](https://nodejs.org/) library that formats the output on STDOUT as [Logstash](https://www.elastic.co/logstash) JSON format. Since version 1.5.0 it is possible to use log tracking. Zenvia Logger uses the [cls-trace](https://www.npmjs.com/package/cls-rtracer) to perform the tracking
3
+ A wrapper for [Winston](https://github.com/winstonjs/winston) Logging [Node.js](https://nodejs.org/) library that formats the output on STDOUT as [Logstash](https://www.elastic.co/logstash) JSON format.
4
+
5
+ **Main features:**
6
+ - **Dual Package Support:** Native support for both **ES Modules (ESM)** and **CommonJS (CJS)**.
7
+ - **Node 24 Ready:** Optimized for native TypeScript execution (Type Stripping).
8
+ - **Distributed Tracing:** Automated request tracking using [cls-rtracer](https://www.npmjs.com/package/cls-rtracer).
4
9
 
5
10
  [![License](https://img.shields.io/github/license/zenvia/zenvia-logger-node.svg)](LICENSE.md)
6
- [![Build Status](https://travis-ci.com/zenvia/zenvia-logger-node.svg?branch=master)](https://travis-ci.com/zenvia/zenvia-logger-node)
11
+ [![NPM Version](https://img.shields.io/npm/v/%40zenvia%2Flogger)](https://www.npmjs.com/package/@zenvia/logger)
7
12
  [![Coverage Status](https://coveralls.io/repos/github/zenvia/zenvia-logger-node/badge.svg?branch=master)](https://coveralls.io/github/zenvia/zenvia-logger-node?branch=master)
8
- [![Dependencies](https://img.shields.io/david/zenvia/zenvia-logger-node.svg)](https://david-dm.org/zenvia/zenvia-logger-node)
9
13
 
10
- [![Twitter Follow](https://img.shields.io/twitter/follow/ZENVIA_.svg?style=social)](https://twitter.com/intent/follow?screen_name=ZENVIA_)
14
+ [![NPM](https://nodei.co/npm/@zenvia/logger.svg)](https://nodei.co/npm/@zenvia/logger/)
11
15
 
16
+ [![Twitter Follow](https://img.shields.io/twitter/follow/ZENVIA_.svg?style=social)](https://twitter.com/intent/follow?screen_name=ZENVIA_)
12
17
 
13
18
 
14
19
  ## Installation
@@ -101,7 +106,7 @@ Output:
101
106
  }
102
107
  ```
103
108
 
104
- ### Available logging levels
109
+ ## Available logging levels
105
110
 
106
111
  The log levels are as follows.
107
112
 
@@ -115,7 +120,7 @@ For backward compatibility purposes, **"verbose"** and **"silly"** levels will b
115
120
 
116
121
 
117
122
 
118
- ### Adding extra key/value fields
123
+ ## Adding extra key/value fields
119
124
 
120
125
  ```js
121
126
  logger.debug('Some text message', { keyA: 'value A', keyB: 'value B' });
@@ -135,7 +140,7 @@ Output:
135
140
  }
136
141
  ```
137
142
 
138
- ### Logging errors
143
+ ## Logging errors
139
144
 
140
145
  ```js
141
146
  logger.error('Ops!', new Error('Something goes wrong'));
@@ -174,7 +179,7 @@ Output:
174
179
  }
175
180
  ```
176
181
 
177
- ### Using trace logs
182
+ ## Using trace logs
178
183
  From version 1.5.0 it is possible to track logs. To do traceability, the cls-rTrace package is used. To use it, just add the middleware in the framework you are using. In this way it is possible to propagate the traceId received in a request to the logs throughout your project. If no traceId is passed in the request, Zenvia Logger generates a random traceId for the request being processed.
179
184
 
180
185
  **Request example sending traceId:**
@@ -218,6 +223,61 @@ Log Output
218
223
  }
219
224
  ```
220
225
 
226
+ ## Contextual Logging (Async Context)
227
+
228
+ You can use `runWithContext` and `addContext` to manage metadata context across asynchronous flows (like Kafka consumers or complex background jobs). This ensures all logs within a specific execution thread share the same context without passing a logger instance manually.
229
+
230
+ ### Basic Usage with runWithContext
231
+
232
+ Useful for isolating logs from different event sources or partitions.
233
+
234
+ ```js
235
+ import logger from '@zenvia/logger';
236
+
237
+ async function handleEvent(event) {
238
+ await logger.runWithContext({
239
+ partition: event.partition,
240
+ topic: 'orders-topic'
241
+ }, async () => {
242
+ // All logs here will include partition and topic automatically
243
+ logger.info('Processing started');
244
+ await processOrder(event.data);
245
+ });
246
+ }
247
+ ```
248
+
249
+ ### Enriching Context with addContext
250
+
251
+ You can inject new metadata into the current context at any point during the execution.
252
+
253
+ ```js
254
+ import logger from '@zenvia/logger';
255
+
256
+ async function processOrder(data) {
257
+ logger.info('Validating...');
258
+
259
+ const provider = await api.getProvider(data.id);
260
+
261
+ // Mutates the current context to include providerId in all subsequent logs
262
+ logger.addContext({ providerId: provider.id });
263
+
264
+ logger.info('Validation complete');
265
+ // Output JSON will contain: partition, topic AND providerId
266
+ }
267
+ ```
268
+
269
+ ### Nested Contexts
270
+
271
+ Contexts can be nested. The inner context will inherit metadata from the parent.
272
+
273
+ ```js
274
+ await logger.runWithContext({ level1: 'A' }, async () => {
275
+ await logger.runWithContext({ level2: 'B' }, async () => {
276
+ logger.info('Log with A and B');
277
+ });
278
+ });
279
+ ```
280
+
221
281
  ## License
222
282
 
223
283
  [MIT](LICENSE.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenvia/logger",
3
- "version": "1.6.1",
3
+ "version": "2.0.0",
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",
@@ -31,19 +31,22 @@
31
31
  "Zenvia",
32
32
  "Winston"
33
33
  ],
34
+ "engines": {
35
+ "node": ">=16.4.0"
36
+ },
34
37
  "dependencies": {
35
38
  "app-root-dir": "^1.0.2",
36
39
  "cls-rtracer": "^2.6.3",
37
- "winston": "^3.11.0"
40
+ "winston": "^3.19.0"
38
41
  },
39
42
  "devDependencies": {
40
43
  "chai": "^4.2.0",
41
44
  "chai-as-promised": "^7.1.1",
42
- "coveralls": "^3.1.0",
45
+ "coveralls-next": "^6.0.1",
43
46
  "eslint": "^7.10.0",
44
47
  "eslint-config-airbnb-base": "^14.2.0",
45
48
  "eslint-plugin-import": "^2.22.1",
46
- "mocha": "^8.1.3",
49
+ "mocha": "^11.7.5",
47
50
  "nyc": "^15.1.0",
48
51
  "sinon": "^9.2.0",
49
52
  "sinon-chai": "^3.5.0",
package/src/index.d.ts CHANGED
@@ -1,2 +1,12 @@
1
- export { default } from './lib/logger';
2
- export { default as traceMiddleware } from './middleware/trace';
1
+
2
+ import { ZenviaLogger } from './lib/logger';
3
+ import traceMiddlewareFactory from './middleware/trace';
4
+
5
+ type TraceMiddlewareInstance = ReturnType<typeof traceMiddlewareFactory>;
6
+ type ZenviaLoggerModule = ZenviaLogger & {
7
+ default: ZenviaLogger;
8
+ traceMiddleware: TraceMiddlewareInstance;
9
+ };
10
+
11
+ declare const loggerModule: ZenviaLoggerModule;
12
+ export = loggerModule;
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
1
- Object.defineProperty(exports, '__esModule', {
2
- value: true,
3
- });
4
-
5
1
  const logger = require('./lib/logger');
6
- const traceMiddleware = require('./middleware/trace');
2
+ const traceMiddlewareFactory = require('./middleware/trace');
3
+
4
+ const traceMiddleware = traceMiddlewareFactory();
7
5
 
8
- exports.default = logger;
9
- exports.traceMiddleware = traceMiddleware();
6
+ module.exports = logger;
7
+ module.exports.logger = logger;
8
+ module.exports.traceMiddleware = traceMiddleware;
9
+ module.exports.default = logger;
@@ -1,8 +1,10 @@
1
1
  import { Logger, LeveledLogMethod } from 'winston';
2
2
 
3
- interface ZenviaLogger extends Logger {
3
+ export interface ZenviaLogger extends Logger {
4
4
  fatal: LeveledLogMethod;
5
5
  isFatalEnabled(): boolean;
6
+ runWithContext<T>(context: object, fn: () => Promise<T> | T): Promise<T>;
7
+ addContext(context: object): void;
6
8
  }
7
9
 
8
10
  declare const logger: ZenviaLogger;
package/src/lib/logger.js CHANGED
@@ -7,28 +7,13 @@ const winston = require('winston');
7
7
  const path = require('path');
8
8
  const appRootDir = require('app-root-dir').get();
9
9
  const rTrace = require('cls-rtracer');
10
+ const { AsyncLocalStorage } = require('node:async_hooks');
10
11
 
11
12
  const appPackage = require(path.join(appRootDir, 'package'));
12
13
 
13
- const sanitizeInfo = (info) => {
14
- const sanitizeCRLFInjection = (str) => str
15
- .replace(/\n|\r/g, (x) => (x === '\n' ? '#n' : '#r'));
16
-
17
- Object.keys(info).forEach((key) => {
18
- if (typeof info[key] === 'string') {
19
- info[key] = sanitizeCRLFInjection(info[key]);
20
- return;
21
- }
22
-
23
- if (info[key] instanceof Function) {
24
- delete info[key];
25
- }
26
- });
27
- };
14
+ const loggerStorage = new AsyncLocalStorage();
28
15
 
29
16
  const customFormatJson = winston.format((info) => {
30
- sanitizeInfo(info);
31
-
32
17
  let stack;
33
18
 
34
19
  if (info.stack) {
@@ -49,6 +34,12 @@ const customFormatJson = winston.format((info) => {
49
34
  traceId: rTrace.id(),
50
35
  };
51
36
 
37
+ Object.keys(info).forEach((key) => {
38
+ if (info[key] instanceof Function) {
39
+ delete info[key];
40
+ }
41
+ });
42
+
52
43
  return info;
53
44
  });
54
45
 
@@ -67,7 +58,7 @@ const createConsoleTransport = () => new winston.transports.Console({
67
58
  stderrLevels: ['fatal', 'error'],
68
59
  });
69
60
 
70
- const logger = winston.createLogger({
61
+ const baseLogger = winston.createLogger({
71
62
  levels: {
72
63
  fatal: 0,
73
64
  error: 1,
@@ -86,6 +77,11 @@ const logger = winston.createLogger({
86
77
  exitOnError: false,
87
78
  });
88
79
 
80
+ function getActiveLogger() {
81
+ const store = loggerStorage.getStore();
82
+ return store ? store.current : baseLogger;
83
+ }
84
+
89
85
  function leveledLogFn(level) {
90
86
  return function log(...args) {
91
87
  return logFn(level, ...args);
@@ -93,8 +89,9 @@ function leveledLogFn(level) {
93
89
  }
94
90
 
95
91
  function logFn(level, msg) {
92
+ const currentLogger = getActiveLogger();
96
93
  if (arguments.length === 1 && typeof level !== 'object') {
97
- return logger;
94
+ return currentLogger;
98
95
  }
99
96
  if (arguments.length === 2) {
100
97
  if (msg && typeof msg === 'object') {
@@ -105,24 +102,43 @@ function logFn(level, msg) {
105
102
  };
106
103
  }
107
104
  }
108
- return logger.realLog(...arguments);
105
+ return currentLogger.log(...arguments);
109
106
  }
110
107
 
111
108
  function isLevelEnabledFn(level) {
112
109
  return function isLevelEnabled() {
113
- return logger.isLevelEnabled(level);
110
+ return getActiveLogger().isLevelEnabled(level);
114
111
  };
115
112
  }
116
113
 
117
- logger.fatal = leveledLogFn('fatal');
118
- logger.error = leveledLogFn('error');
119
- logger.warn = leveledLogFn('warn');
120
- logger.info = leveledLogFn('info');
121
- logger.debug = leveledLogFn('debug');
122
- logger.verbose = leveledLogFn('verbose');
123
- logger.silly = leveledLogFn('silly');
124
- logger.isFatalEnabled = isLevelEnabledFn('fatal');
125
- logger.realLog = logger.log;
126
- logger.log = logFn;
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
+ },
132
+
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');
139
+ }
140
+ },
141
+ };
127
142
 
128
143
  module.exports = logger;
144
+ module.exports.default = logger;
@@ -1,9 +1,10 @@
1
1
  import rTracer from 'cls-rtracer';
2
2
 
3
- declare const traceMiddleware: () =>
3
+ export type TracerMiddlewareResult =
4
4
  ReturnType<typeof rTracer.expressMiddleware> |
5
5
  ReturnType<typeof rTracer.fastifyPlugin> |
6
6
  typeof rTracer.hapiPlugin |
7
7
  ReturnType<typeof rTracer.koaMiddleware>;
8
8
 
9
- export default traceMiddleware;
9
+ declare const traceMiddlewareFactory: () => TracerMiddlewareResult;
10
+ export default traceMiddlewareFactory;
@@ -1,6 +1,6 @@
1
1
  const rTracer = require('cls-rtracer');
2
2
 
3
- module.exports = () => {
3
+ const traceMiddlewareFactory = () => {
4
4
  const frameworkMiddleware = process.env.LOGGING_FRAMEWORK_MIDDLEWARE;
5
5
 
6
6
  const RTRACER_OPTIONS = {
@@ -23,3 +23,7 @@ module.exports = () => {
23
23
 
24
24
  return middleware[frameworkMiddleware || 'EXPRESS'];
25
25
  };
26
+
27
+ module.exports = traceMiddlewareFactory;
28
+ module.exports.traceMiddleware = traceMiddlewareFactory;
29
+ module.exports.default = traceMiddlewareFactory;
@@ -70,6 +70,21 @@ describe('Logger test', () => {
70
70
  JSON.parse(actualOutput).should.be.deep.equal(expectedOutput);
71
71
  });
72
72
 
73
+ it('should remove attributes that are log functions, leaving only the @timestamp, application, message and level fields', () => {
74
+ logger.info('some message', { field1: () => {} });
75
+ const expectedOutput = {
76
+ '@timestamp': '2018-06-05T18:20:42.345Z',
77
+ '@version': 1,
78
+ application: 'application-name',
79
+ host: os.hostname(),
80
+ message: 'some message',
81
+ level: 'INFO',
82
+ };
83
+
84
+ const actualOutput = stdMocks.flush().stdout[0];
85
+ JSON.parse(actualOutput).should.be.deep.equal(expectedOutput);
86
+ });
87
+
73
88
  it('should log @timestamp, application, message, level and environment fields', () => {
74
89
  process.env.NODE_ENV = 'test';
75
90
  logger.info('some message');
@@ -241,50 +256,6 @@ describe('Logger test', () => {
241
256
  });
242
257
 
243
258
  describe('Logging format', () => {
244
- it('should replace LF characters from log (POSIX systems)', () => {
245
- logger.debug(`some message
246
- other CRLF injection message`);
247
- const expectedOutput = {
248
- '@timestamp': '2018-06-05T18:20:42.345Z',
249
- '@version': 1,
250
- application: 'application-name',
251
- host: os.hostname(),
252
- message: 'some message#nother CRLF injection message',
253
- level: 'DEBUG',
254
- };
255
-
256
- const actualOutput = stdMocks.flush().stdout[0];
257
- JSON.parse(actualOutput).should.be.deep.equal(expectedOutput);
258
-
259
- logger.debug('some\n CRLF\n injection\n message');
260
- const expectedOutput2 = {
261
- '@timestamp': '2018-06-05T18:20:42.345Z',
262
- '@version': 1,
263
- application: 'application-name',
264
- host: os.hostname(),
265
- message: 'some#n CRLF#n injection#n message',
266
- level: 'DEBUG',
267
- };
268
-
269
- const actualOutput2 = stdMocks.flush().stdout[0];
270
- JSON.parse(actualOutput2).should.be.deep.equal(expectedOutput2);
271
- });
272
-
273
- it('should replace CRLF characters from log (Windows systems)', () => {
274
- logger.debug('some\r\n CRLF\r\n injection\r\n message');
275
- const expectedOutput = {
276
- '@timestamp': '2018-06-05T18:20:42.345Z',
277
- '@version': 1,
278
- application: 'application-name',
279
- host: os.hostname(),
280
- message: 'some#r#n CRLF#r#n injection#r#n message',
281
- level: 'DEBUG',
282
- };
283
-
284
- const actualOutput = stdMocks.flush().stdout[0];
285
- JSON.parse(actualOutput).should.be.deep.equal(expectedOutput);
286
- });
287
-
288
259
  it('should get not format when LOGGING_FORMATTER_DISABLED environment is true', () => {
289
260
  delete require.cache[require.resolve('../../src/lib/logger')];
290
261
  process.env.LOGGING_FORMATTER_DISABLED = 'true';
@@ -328,4 +299,89 @@ other CRLF injection message`);
328
299
  obj.should.not.have.property('level');
329
300
  });
330
301
  });
302
+
303
+ describe('Contextual Logging (AsyncLocalStorage)', () => {
304
+ 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();
307
+
308
+ logger.info('message inside context');
309
+
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
+ });
315
+ });
316
+
317
+ it('should update context metadata using addContext (mutation)', async () => {
318
+ await logger.runWithContext({ requestId: 'initial-id' }, async () => {
319
+ logger.info('first log');
320
+ const output1 = JSON.parse(stdMocks.flush().stdout[0]);
321
+ output1.should.not.have.property('providerId');
322
+
323
+ logger.addContext({ providerId: 'zenvia-123' });
324
+
325
+ logger.info('second log');
326
+ const output2 = JSON.parse(stdMocks.flush().stdout[0]);
327
+ output2.should.have.property('requestId').and.be.equal('initial-id');
328
+ output2.should.have.property('providerId').and.be.equal('zenvia-123');
329
+ });
330
+ });
331
+
332
+ it('should warn when addContext is called outside of an active context', () => {
333
+ logger.addContext({ some: 'data' });
334
+
335
+ const actualOutput = stdMocks.flush().stdout[0];
336
+ const parsedOutput = JSON.parse(actualOutput);
337
+
338
+ 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');
340
+ });
341
+
342
+ it('should isolate contexts between concurrent executions', async () => {
343
+ const flow1 = logger.runWithContext({ flow: 1 }, async () => {
344
+ await Promise.resolve();
345
+ logger.info('log flow 1');
346
+ });
347
+
348
+ const flow2 = logger.runWithContext({ flow: 2 }, async () => {
349
+ await Promise.resolve();
350
+ logger.info('log flow 2');
351
+ });
352
+
353
+ await Promise.all([flow1, flow2]);
354
+
355
+ const logs = stdMocks.flush().stdout.map((line) => JSON.parse(line));
356
+
357
+ const logFlow2 = logs.find((l) => l.message === 'log flow 2');
358
+ const logFlow1 = logs.find((l) => l.message === 'log flow 1');
359
+
360
+ logFlow2.should.have.property('flow').and.be.equal(2);
361
+ logFlow1.should.have.property('flow').and.be.equal(1);
362
+ });
363
+
364
+ it('should not leak metadata to logs outside of context', async () => {
365
+ await logger.runWithContext({ internal: 'secret' }, async () => {
366
+ logger.info('inside');
367
+ });
368
+
369
+ logger.info('outside');
370
+
371
+ const outputs = stdMocks.flush().stdout.map((line) => JSON.parse(line));
372
+ outputs[0].should.have.property('internal').and.be.equal('secret');
373
+ outputs[1].should.not.have.property('internal');
374
+ });
375
+
376
+ it('should allow nested contexts (re-nesting via runWithContext)', async () => {
377
+ await logger.runWithContext({ level1: 'a' }, async () => {
378
+ await logger.runWithContext({ level2: 'b' }, async () => {
379
+ logger.info('nested log');
380
+ const output = JSON.parse(stdMocks.flush().stdout[0]);
381
+ output.should.have.property('level1').and.be.equal('a');
382
+ output.should.have.property('level2').and.be.equal('b');
383
+ });
384
+ });
385
+ });
386
+ });
331
387
  });