ntlogger 2.9.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugins/teams.js CHANGED
@@ -4,8 +4,7 @@
4
4
  */
5
5
 
6
6
  const Transport = require('winston-transport');
7
- const https = require('https');
8
- const { URL } = require('url');
7
+ const { HttpDelivery } = require('./lib/httpDelivery');
9
8
 
10
9
  const levels = require('../lib/levels');
11
10
 
@@ -16,13 +15,9 @@ class TeamsTransport extends Transport {
16
15
  this.name = 'Teams Webhook Transport for NTLogger';
17
16
 
18
17
  this.webhookUrl = opts.webhookUrl;
18
+ this.delivery = new HttpDelivery(opts.webhookUrl, opts);
19
19
  this.strict = opts.strict || false;
20
20
 
21
- // Retry logic with exponential back-off
22
- // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL%2Ctext1#rate-limiting-for-connectors
23
- this.maxRetries = opts.maxRetries || 3;
24
- this.retryDelay = opts.retryDelay || 1000; // Initial delay for exponential back-off (in ms)
25
-
26
21
  try {
27
22
  // Set the log level
28
23
  this.level = opts.level || 'info';
@@ -38,10 +33,6 @@ class TeamsTransport extends Transport {
38
33
  }
39
34
 
40
35
  async log(info, callback) {
41
- setImmediate(() => {
42
- this.emit('logged', info);
43
- });
44
-
45
36
  const { level, message, ...meta } = info;
46
37
 
47
38
  // Check if the log level matches the configured level (strict mode) or is at or below the configured level
@@ -57,62 +48,14 @@ class TeamsTransport extends Transport {
57
48
  }
58
49
  }
59
50
 
60
- const payload = this.createPayload(level, message, meta);
61
- const webhookUrl = new URL(this.webhookUrl);
62
-
63
- const options = {
64
- hostname: webhookUrl.hostname,
65
- path: webhookUrl.pathname + webhookUrl.search,
66
- method: 'POST',
67
- headers: {
68
- 'Content-Type': 'application/json',
69
- 'Content-Length': Buffer.byteLength(payload),
70
- },
71
- };
72
-
73
- const sendLog = (retryCount = 0) => {
74
- const req = https.request(options, (res) => {
75
- let responseContent = '';
76
-
77
- res.on('data', (chunk) => {
78
- responseContent += chunk;
79
- });
80
-
81
- res.on('end', () => {
82
- if (responseContent.includes("Microsoft Teams endpoint returned HTTP error 429")) {
83
- console.error('Rate limit hit, retrying with exponential back-off...');
84
- if (retryCount < this.maxRetries) {
85
- setTimeout(() => {
86
- sendLog(retryCount + 1);
87
- }, this.retryDelay * Math.pow(2, retryCount)); // Exponential back-off
88
- } else {
89
- console.error('Max retries reached, log sending failed.');
90
- callback();
91
- }
92
- } else {
93
- callback();
94
- }
95
- });
96
- });
97
-
98
- req.on('error', (e) => {
99
- console.error(`Failed to send log to Teams: ${e.message}`);
100
- if (retryCount < this.maxRetries) {
101
- console.log(`Retrying... (${retryCount + 1}/${this.maxRetries})`);
102
- setTimeout(() => {
103
- sendLog(retryCount + 1);
104
- }, this.retryDelay * Math.pow(2, retryCount));
105
- } else {
106
- console.error('Max retries reached, log sending failed.');
107
- callback();
108
- }
109
- });
110
-
111
- req.write(payload);
112
- req.end();
113
- };
51
+ try {
52
+ const payload = this.createPayload(level, message, meta);
53
+ if (this.closed) throw new Error('HTTP transport is closed');
54
+ await this.delivery.send(payload);
55
+ this.emit('logged', info);
56
+ callback();
57
+ } catch (error) { callback(error); }
114
58
 
115
- sendLog();
116
59
  }
117
60
 
118
61
  createPayload(level, message, meta) {
@@ -132,7 +75,7 @@ class TeamsTransport extends Transport {
132
75
  const metaArray = Array.isArray(meta) ? meta : Object.values(meta);
133
76
 
134
77
  metaArray.forEach(item => {
135
- if (typeof item !== 'object' || !item.type) {
78
+ if (!item || typeof item !== 'object' || !item.type) {
136
79
  return;
137
80
  }
138
81
 
@@ -304,6 +247,12 @@ class TeamsTransport extends Transport {
304
247
  ]
305
248
  });
306
249
  }
250
+ flush() { return this.delivery.flush(); }
251
+ close() {
252
+ this.closed = true;
253
+ return this.delivery.flush();
254
+ }
255
+
307
256
  }
308
257
 
309
258
  module.exports = {
@@ -1,6 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  const split = require('split2');
4
+ const { isMainThread } = require('worker_threads');
5
+ const fs = require('fs');
6
+
7
+ function writeOutput(line) {
8
+ if (isMainThread) return process.stdout.write(line);
9
+ // ThreadStream.end() can block the parent on Atomics.wait. Worker stdout
10
+ // callbacks need that parent's event loop, so write to the fd directly.
11
+ const bytes = Buffer.from(line);
12
+ let offset = 0;
13
+ while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset, bytes.length - offset);
14
+ }
4
15
  const colors = require('../lib/colors');
5
16
 
6
17
  const levelColors = colors.console;
@@ -38,6 +49,31 @@ const STATUS_COLORS = {
38
49
  5: '\x1b[31m',
39
50
  };
40
51
 
52
+ /**
53
+ * Structured context fields rendered as a compact `{k=v ...}` suffix right
54
+ * after the module label. Order here is the render order.
55
+ */
56
+ const CONTEXT_KEYS = [
57
+ 'tenantId',
58
+ 'userId',
59
+ 'correlationId',
60
+ 'traceId',
61
+ 'spanId',
62
+ 'jobId',
63
+ 'agentUuid',
64
+ 'commandId',
65
+ ];
66
+
67
+ /**
68
+ * Boilerplate messages emitted by pino-http/Fastify for the request-start
69
+ * line. They carry no information once the method and URL are rendered.
70
+ */
71
+ const GENERIC_REQUEST_MESSAGES = new Set(['incoming request', 'request received']);
72
+
73
+ // Defaults for the in-flight request map. Both are overridable per transport.
74
+ const DEFAULT_MAX_INFLIGHT_REQUESTS = 1000;
75
+ const DEFAULT_INFLIGHT_TTL_MS = 60000;
76
+
41
77
  /**
42
78
  * Format a timestamp to YYYY-MM-DD HH:mm:ss (NTL style).
43
79
  */
@@ -59,14 +95,108 @@ function padLevel(name) {
59
95
  return name.padEnd(8);
60
96
  }
61
97
 
98
+ /**
99
+ * Bounded map of in-flight HTTP requests.
100
+ *
101
+ * pino-http/Fastify split a request across two lines: the request-start line
102
+ * carries `req.method` / `req.url`, the completion line carries only
103
+ * `res.statusCode` and `responseTime`. Remembering the start line lets the
104
+ * completion line render `METHOD /path — STATUS (Nms)`.
105
+ *
106
+ * A request that never completes (aborted socket, crashed handler) would leak
107
+ * an entry forever, so entries expire by age and the map is capped. Both
108
+ * sweeps are lazy — no timers, nothing that can keep a process alive.
109
+ *
110
+ * @param {Object} opts
111
+ * @param {number} [opts.maxInflightRequests=1000] Hard cap on tracked requests.
112
+ * @param {number} [opts.inflightTtlMs=60000] Max age of a tracked request.
113
+ */
114
+ function createRequestTracker(opts = {}) {
115
+ const max = Number.isFinite(opts.maxInflightRequests) && opts.maxInflightRequests > 0
116
+ ? Math.floor(opts.maxInflightRequests)
117
+ : DEFAULT_MAX_INFLIGHT_REQUESTS;
118
+ const ttl = Number.isFinite(opts.inflightTtlMs) && opts.inflightTtlMs > 0
119
+ ? opts.inflightTtlMs
120
+ : DEFAULT_INFLIGHT_TTL_MS;
121
+
122
+ // Map preserves insertion order, and entries are always (re)inserted at the
123
+ // tail, so the head is always the oldest entry.
124
+ const entries = new Map();
125
+
126
+ function prune(now = Date.now()) {
127
+ for (const [key, value] of entries) {
128
+ if (now - value.at < ttl) break;
129
+ entries.delete(key);
130
+ }
131
+ while (entries.size > max) {
132
+ const oldest = entries.keys().next();
133
+ if (oldest.done) break;
134
+ entries.delete(oldest.value);
135
+ }
136
+ }
137
+
138
+ return {
139
+ /**
140
+ * Remember an in-flight request. Re-tracking an id refreshes its position.
141
+ */
142
+ track(id, method, url, now = Date.now()) {
143
+ if (!id) return;
144
+ if (entries.has(id)) entries.delete(id);
145
+ entries.set(id, { method: method || '', url: url || '', at: now });
146
+ prune(now);
147
+ },
148
+ /**
149
+ * Look up and forget a tracked request.
150
+ * @returns {{method: string, url: string}|null}
151
+ */
152
+ take(id) {
153
+ if (!id) return null;
154
+ const entry = entries.get(id);
155
+ if (!entry) return null;
156
+ entries.delete(id);
157
+ return { method: entry.method, url: entry.url };
158
+ },
159
+ prune,
160
+ get size() {
161
+ return entries.size;
162
+ },
163
+ get maxInflightRequests() {
164
+ return max;
165
+ },
166
+ get inflightTtlMs() {
167
+ return ttl;
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Render the structured context suffix, e.g. `{tenantId=t1 correlationId=c9}`.
174
+ * Only keys that are actually present (and non-empty) are rendered.
175
+ *
176
+ * @returns {string} Dim-wrapped suffix, or '' when no context keys are present.
177
+ */
178
+ function formatContext(obj) {
179
+ const pairs = [];
180
+ for (const key of CONTEXT_KEYS) {
181
+ const value = obj[key];
182
+ if (value === undefined || value === null || value === '') continue;
183
+ pairs.push(`${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`);
184
+ }
185
+ if (pairs.length === 0) return '';
186
+ return `${DIM}{${pairs.join(' ')}}${RESET}`;
187
+ }
188
+
62
189
  /**
63
190
  * Format a single Pino log object into an NTL-style string.
64
191
  *
65
192
  * @param {Object} obj - Pino JSON log object
66
193
  * @param {Object} opts - Transport options
194
+ * @param {Object} [tracker] - Optional in-flight request tracker (see
195
+ * createRequestTracker). When omitted, request/response pairing is skipped
196
+ * and only inline `req` data is used.
67
197
  * @returns {string} Formatted log line
68
198
  */
69
- function formatLine(obj, opts = {}) {
199
+ function formatLine(obj, opts = {}, tracker = null) {
70
200
  const parts = [];
71
201
 
72
202
  // 1. Timestamp
@@ -89,20 +219,45 @@ function formatLine(obj, opts = {}) {
89
219
  parts.push(`${GREEN}[${location}]${RESET}:`);
90
220
  }
91
221
 
92
- // 5. Message
222
+ // 5. Structured context — {tenantId=… correlationId=…}
223
+ const context = formatContext(obj);
224
+ if (context) {
225
+ parts.push(context);
226
+ }
227
+
228
+ // 6. Message
93
229
  let msg = obj.msg || obj.message || '';
94
230
 
95
- // 6. HTTP request completion — format as: METHOD /path — STATUS (TIMEms)
96
- if (obj.res && obj.responseTime !== undefined) {
231
+ if (obj.req && !obj.res) {
232
+ // Request start remember it so the completion line can name the route.
233
+ const method = (obj.req.method) || '';
234
+ const url = (obj.req.url) || '';
235
+ if (tracker && (method || url)) {
236
+ tracker.track(id, method, url);
237
+ }
238
+ if (method && url) {
239
+ msg = GENERIC_REQUEST_MESSAGES.has(msg) || !msg
240
+ ? `${method} ${url}`
241
+ : `${method} ${url} ${GREY}—${RESET} ${msg}`;
242
+ }
243
+ } else if (obj.res && obj.responseTime !== undefined) {
244
+ // 7. HTTP request completion — format as: METHOD /path — STATUS (TIMEms)
97
245
  const status = obj.res.statusCode;
98
246
  const statusGroup = Math.floor(status / 100);
99
247
  const statusColor = STATUS_COLORS[statusGroup] || '';
100
- const method = (obj.req && obj.req.method) || '';
101
- const url = (obj.req && obj.req.url) || '';
102
248
  const time = typeof obj.responseTime === 'number'
103
249
  ? obj.responseTime.toFixed(1)
104
250
  : obj.responseTime;
105
251
 
252
+ // Inline req wins; otherwise fall back to the tracked request-start line.
253
+ let method = (obj.req && obj.req.method) || '';
254
+ let url = (obj.req && obj.req.url) || '';
255
+ const tracked = tracker ? tracker.take(id) : null;
256
+ if (!(method && url) && tracked) {
257
+ method = tracked.method;
258
+ url = tracked.url;
259
+ }
260
+
106
261
  if (method && url) {
107
262
  msg = `${method} ${url} — ${statusColor}${status}${RESET} ${GREY}(${time}ms)${RESET}`;
108
263
  } else if (msg) {
@@ -112,7 +267,7 @@ function formatLine(obj, opts = {}) {
112
267
  }
113
268
  }
114
269
 
115
- // 7. Error stack traces
270
+ // 8. Error stack traces
116
271
  if (obj.err) {
117
272
  const stack = obj.err.stack || obj.err.message || '';
118
273
  if (stack) {
@@ -122,7 +277,8 @@ function formatLine(obj, opts = {}) {
122
277
 
123
278
  parts.push(msg);
124
279
 
125
- return parts.join(' ');
280
+ const line = parts.join(' ');
281
+ return opts.colorize === false ? line.replace(/\x1b\[[0-9;]*m/g, '') : line;
126
282
  }
127
283
 
128
284
  /**
@@ -149,24 +305,44 @@ function formatLine(obj, opts = {}) {
149
305
  *
150
306
  * Options:
151
307
  * - defaultModule: Default location label when obj.module is not set (e.g., 'API')
308
+ * - colorize: Set to false to strip all ANSI codes from the output
309
+ * - maxInflightRequests: Cap on remembered in-flight requests (default 1000)
310
+ * - inflightTtlMs: Max age of a remembered in-flight request (default 60000)
152
311
  *
153
312
  * @param {Object} opts - Transport options
154
313
  */
155
314
  module.exports = function (opts = {}) {
156
- return split(function (line) {
315
+ const tracker = createRequestTracker(opts);
316
+
317
+ const stream = split(function (line) {
157
318
  let obj;
158
319
  try {
159
320
  obj = JSON.parse(line);
160
321
  } catch {
161
- process.stdout.write(line + '\n');
322
+ writeOutput(line + '\n');
162
323
  return;
163
324
  }
164
- const formatted = formatLine(obj, opts);
165
- process.stdout.write(formatted + '\n');
325
+ const formatted = formatLine(obj, opts, tracker);
326
+ writeOutput(formatted + '\n');
327
+ });
328
+ // The worker consumes only the writable side; drain the readable side so
329
+ // split2 can emit end/close and let Pino terminate its worker.
330
+ const flush = stream._flush.bind(stream);
331
+ stream._flush = callback => flush(error => {
332
+ if (error) return callback(error);
333
+ // stdout is asynchronous in a Pino worker. Wait for preceding writes before
334
+ // signalling completion, otherwise worker shutdown can discard the output.
335
+ if (isMainThread) process.stdout.write('', callback);
336
+ else callback();
166
337
  });
338
+ stream.resume();
339
+ return stream;
167
340
  };
168
341
 
169
342
  // Export internals for testing
170
343
  module.exports.formatLine = formatLine;
171
344
  module.exports.formatTimestamp = formatTimestamp;
172
345
  module.exports.padLevel = padLevel;
346
+ module.exports.formatContext = formatContext;
347
+ module.exports.createRequestTracker = createRequestTracker;
348
+ module.exports.CONTEXT_KEYS = CONTEXT_KEYS;