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.
@@ -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,36 @@ 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
+ 'collectionCycleId',
66
+ 'runId',
67
+ 'workId',
68
+ 'stageId',
69
+ 'deviceId',
70
+ ];
71
+
72
+ /**
73
+ * Boilerplate messages emitted by pino-http/Fastify for the request-start
74
+ * line. They carry no information once the method and URL are rendered.
75
+ */
76
+ const GENERIC_REQUEST_MESSAGES = new Set(['incoming request', 'request received']);
77
+
78
+ // Defaults for the in-flight request map. Both are overridable per transport.
79
+ const DEFAULT_MAX_INFLIGHT_REQUESTS = 1000;
80
+ const DEFAULT_INFLIGHT_TTL_MS = 60000;
81
+
41
82
  /**
42
83
  * Format a timestamp to YYYY-MM-DD HH:mm:ss (NTL style).
43
84
  */
@@ -59,14 +100,108 @@ function padLevel(name) {
59
100
  return name.padEnd(8);
60
101
  }
61
102
 
103
+ /**
104
+ * Bounded map of in-flight HTTP requests.
105
+ *
106
+ * pino-http/Fastify split a request across two lines: the request-start line
107
+ * carries `req.method` / `req.url`, the completion line carries only
108
+ * `res.statusCode` and `responseTime`. Remembering the start line lets the
109
+ * completion line render `METHOD /path — STATUS (Nms)`.
110
+ *
111
+ * A request that never completes (aborted socket, crashed handler) would leak
112
+ * an entry forever, so entries expire by age and the map is capped. Both
113
+ * sweeps are lazy — no timers, nothing that can keep a process alive.
114
+ *
115
+ * @param {Object} opts
116
+ * @param {number} [opts.maxInflightRequests=1000] Hard cap on tracked requests.
117
+ * @param {number} [opts.inflightTtlMs=60000] Max age of a tracked request.
118
+ */
119
+ function createRequestTracker(opts = {}) {
120
+ const max = Number.isFinite(opts.maxInflightRequests) && opts.maxInflightRequests > 0
121
+ ? Math.floor(opts.maxInflightRequests)
122
+ : DEFAULT_MAX_INFLIGHT_REQUESTS;
123
+ const ttl = Number.isFinite(opts.inflightTtlMs) && opts.inflightTtlMs > 0
124
+ ? opts.inflightTtlMs
125
+ : DEFAULT_INFLIGHT_TTL_MS;
126
+
127
+ // Map preserves insertion order, and entries are always (re)inserted at the
128
+ // tail, so the head is always the oldest entry.
129
+ const entries = new Map();
130
+
131
+ function prune(now = Date.now()) {
132
+ for (const [key, value] of entries) {
133
+ if (now - value.at < ttl) break;
134
+ entries.delete(key);
135
+ }
136
+ while (entries.size > max) {
137
+ const oldest = entries.keys().next();
138
+ if (oldest.done) break;
139
+ entries.delete(oldest.value);
140
+ }
141
+ }
142
+
143
+ return {
144
+ /**
145
+ * Remember an in-flight request. Re-tracking an id refreshes its position.
146
+ */
147
+ track(id, method, url, now = Date.now()) {
148
+ if (!id) return;
149
+ if (entries.has(id)) entries.delete(id);
150
+ entries.set(id, { method: method || '', url: url || '', at: now });
151
+ prune(now);
152
+ },
153
+ /**
154
+ * Look up and forget a tracked request.
155
+ * @returns {{method: string, url: string}|null}
156
+ */
157
+ take(id) {
158
+ if (!id) return null;
159
+ const entry = entries.get(id);
160
+ if (!entry) return null;
161
+ entries.delete(id);
162
+ return { method: entry.method, url: entry.url };
163
+ },
164
+ prune,
165
+ get size() {
166
+ return entries.size;
167
+ },
168
+ get maxInflightRequests() {
169
+ return max;
170
+ },
171
+ get inflightTtlMs() {
172
+ return ttl;
173
+ },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Render the structured context suffix, e.g. `{tenantId=t1 correlationId=c9}`.
179
+ * Only keys that are actually present (and non-empty) are rendered.
180
+ *
181
+ * @returns {string} Dim-wrapped suffix, or '' when no context keys are present.
182
+ */
183
+ function formatContext(obj) {
184
+ const pairs = [];
185
+ for (const key of CONTEXT_KEYS) {
186
+ const value = obj[key];
187
+ if (value === undefined || value === null || value === '') continue;
188
+ pairs.push(`${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`);
189
+ }
190
+ if (pairs.length === 0) return '';
191
+ return `${DIM}{${pairs.join(' ')}}${RESET}`;
192
+ }
193
+
62
194
  /**
63
195
  * Format a single Pino log object into an NTL-style string.
64
196
  *
65
197
  * @param {Object} obj - Pino JSON log object
66
198
  * @param {Object} opts - Transport options
199
+ * @param {Object} [tracker] - Optional in-flight request tracker (see
200
+ * createRequestTracker). When omitted, request/response pairing is skipped
201
+ * and only inline `req` data is used.
67
202
  * @returns {string} Formatted log line
68
203
  */
69
- function formatLine(obj, opts = {}) {
204
+ function formatLine(obj, opts = {}, tracker = null) {
70
205
  const parts = [];
71
206
 
72
207
  // 1. Timestamp
@@ -89,20 +224,45 @@ function formatLine(obj, opts = {}) {
89
224
  parts.push(`${GREEN}[${location}]${RESET}:`);
90
225
  }
91
226
 
92
- // 5. Message
227
+ // 5. Structured context — {tenantId=… correlationId=…}
228
+ const context = formatContext(obj);
229
+ if (context) {
230
+ parts.push(context);
231
+ }
232
+
233
+ // 6. Message
93
234
  let msg = obj.msg || obj.message || '';
94
235
 
95
- // 6. HTTP request completion — format as: METHOD /path — STATUS (TIMEms)
96
- if (obj.res && obj.responseTime !== undefined) {
236
+ if (obj.req && !obj.res) {
237
+ // Request start remember it so the completion line can name the route.
238
+ const method = (obj.req.method) || '';
239
+ const url = (obj.req.url) || '';
240
+ if (tracker && (method || url)) {
241
+ tracker.track(id, method, url);
242
+ }
243
+ if (method && url) {
244
+ msg = GENERIC_REQUEST_MESSAGES.has(msg) || !msg
245
+ ? `${method} ${url}`
246
+ : `${method} ${url} ${GREY}—${RESET} ${msg}`;
247
+ }
248
+ } else if (obj.res && obj.responseTime !== undefined) {
249
+ // 7. HTTP request completion — format as: METHOD /path — STATUS (TIMEms)
97
250
  const status = obj.res.statusCode;
98
251
  const statusGroup = Math.floor(status / 100);
99
252
  const statusColor = STATUS_COLORS[statusGroup] || '';
100
- const method = (obj.req && obj.req.method) || '';
101
- const url = (obj.req && obj.req.url) || '';
102
253
  const time = typeof obj.responseTime === 'number'
103
254
  ? obj.responseTime.toFixed(1)
104
255
  : obj.responseTime;
105
256
 
257
+ // Inline req wins; otherwise fall back to the tracked request-start line.
258
+ let method = (obj.req && obj.req.method) || '';
259
+ let url = (obj.req && obj.req.url) || '';
260
+ const tracked = tracker ? tracker.take(id) : null;
261
+ if (!(method && url) && tracked) {
262
+ method = tracked.method;
263
+ url = tracked.url;
264
+ }
265
+
106
266
  if (method && url) {
107
267
  msg = `${method} ${url} — ${statusColor}${status}${RESET} ${GREY}(${time}ms)${RESET}`;
108
268
  } else if (msg) {
@@ -112,7 +272,7 @@ function formatLine(obj, opts = {}) {
112
272
  }
113
273
  }
114
274
 
115
- // 7. Error stack traces
275
+ // 8. Error stack traces
116
276
  if (obj.err) {
117
277
  const stack = obj.err.stack || obj.err.message || '';
118
278
  if (stack) {
@@ -122,7 +282,8 @@ function formatLine(obj, opts = {}) {
122
282
 
123
283
  parts.push(msg);
124
284
 
125
- return parts.join(' ');
285
+ const line = parts.join(' ');
286
+ return opts.colorize === false ? line.replace(/\x1b\[[0-9;]*m/g, '') : line;
126
287
  }
127
288
 
128
289
  /**
@@ -149,24 +310,44 @@ function formatLine(obj, opts = {}) {
149
310
  *
150
311
  * Options:
151
312
  * - defaultModule: Default location label when obj.module is not set (e.g., 'API')
313
+ * - colorize: Set to false to strip all ANSI codes from the output
314
+ * - maxInflightRequests: Cap on remembered in-flight requests (default 1000)
315
+ * - inflightTtlMs: Max age of a remembered in-flight request (default 60000)
152
316
  *
153
317
  * @param {Object} opts - Transport options
154
318
  */
155
319
  module.exports = function (opts = {}) {
156
- return split(function (line) {
320
+ const tracker = createRequestTracker(opts);
321
+
322
+ const stream = split(function (line) {
157
323
  let obj;
158
324
  try {
159
325
  obj = JSON.parse(line);
160
326
  } catch {
161
- process.stdout.write(line + '\n');
327
+ writeOutput(line + '\n');
162
328
  return;
163
329
  }
164
- const formatted = formatLine(obj, opts);
165
- process.stdout.write(formatted + '\n');
330
+ const formatted = formatLine(obj, opts, tracker);
331
+ writeOutput(formatted + '\n');
332
+ });
333
+ // The worker consumes only the writable side; drain the readable side so
334
+ // split2 can emit end/close and let Pino terminate its worker.
335
+ const flush = stream._flush.bind(stream);
336
+ stream._flush = callback => flush(error => {
337
+ if (error) return callback(error);
338
+ // stdout is asynchronous in a Pino worker. Wait for preceding writes before
339
+ // signalling completion, otherwise worker shutdown can discard the output.
340
+ if (isMainThread) process.stdout.write('', callback);
341
+ else callback();
166
342
  });
343
+ stream.resume();
344
+ return stream;
167
345
  };
168
346
 
169
347
  // Export internals for testing
170
348
  module.exports.formatLine = formatLine;
171
349
  module.exports.formatTimestamp = formatTimestamp;
172
350
  module.exports.padLevel = padLevel;
351
+ module.exports.formatContext = formatContext;
352
+ module.exports.createRequestTracker = createRequestTracker;
353
+ module.exports.CONTEXT_KEYS = CONTEXT_KEYS;