@zero-server/observe 0.9.1 → 0.9.3

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,359 @@
1
+ /**
2
+ * @module observe/logger
3
+ * @description Structured, enterprise-grade request logger.
4
+ * Outputs JSON or pretty-text with consistent fields:
5
+ * `requestId`, `method`, `url`, `status`, `duration`, `ip`, `userAgent`.
6
+ *
7
+ * Correlates with the `requestId` middleware (`req.id`),
8
+ * supports child loggers with bound context, custom transports,
9
+ * and environment-aware log level defaults.
10
+ *
11
+ * @example
12
+ * const { structuredLogger } = require('@zero-server/sdk');
13
+ * app.use(structuredLogger());
14
+ *
15
+ * @example | JSON Output with Custom Transport
16
+ * app.use(structuredLogger({
17
+ * format: 'json',
18
+ * transport: (entry) => myLogService.send(entry),
19
+ * }));
20
+ *
21
+ * @example | Child Loggers with Bound Context
22
+ * app.get('/users/:id', (req, res) => {
23
+ * const log = req.log.child({ userId: req.params.id });
24
+ * log.info('fetching user');
25
+ * log.warn('user has legacy data');
26
+ * });
27
+ */
28
+ const crypto = require('crypto');
29
+
30
+ // -- Constants -----------------------------------------------------
31
+
32
+ const LEVELS = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5, silent: 6 };
33
+ const LEVEL_NAMES = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
34
+ const LEVEL_COLORS = ['\x1b[2m', '\x1b[36m', '\x1b[32m', '\x1b[33m', '\x1b[31m', '\x1b[35;1m'];
35
+ const RESET = '\x1b[0m';
36
+ const DIM = '\x1b[2m';
37
+
38
+ /**
39
+ * Default log-level per NODE_ENV.
40
+ * @private
41
+ */
42
+ function _defaultLevel()
43
+ {
44
+ const env = process.env.NODE_ENV || 'development';
45
+ if (env === 'test') return LEVELS.silent;
46
+ if (env === 'production') return LEVELS.info;
47
+ return LEVELS.debug;
48
+ }
49
+
50
+ // -- Logger Core ---------------------------------------------------
51
+
52
+ /**
53
+ * Lightweight structured logger instance (not middleware).
54
+ * Used internally by child loggers and the request logger.
55
+ */
56
+ class Logger
57
+ {
58
+ /**
59
+ * @constructor
60
+ * @param {object} [opts] - Logger options.
61
+ * @param {string|number} [opts.level] - Minimum log level.
62
+ * @param {object} [opts.context] - Bound context fields merged into every entry.
63
+ * @param {Function} [opts.transport] - Custom transport `(entry) => void`.
64
+ * @param {boolean} [opts.json=false] - Force JSON output.
65
+ * @param {boolean} [opts.colors] - Enable ANSI colors (default: TTY detection).
66
+ * @param {boolean} [opts.timestamps=true] - Include timestamps.
67
+ * @param {WritableStream} [opts.stream] - Output stream (default: stdout/stderr).
68
+ */
69
+ constructor(opts = {})
70
+ {
71
+ this._level = typeof opts.level === 'string'
72
+ ? (LEVELS[opts.level] ?? _defaultLevel())
73
+ : (typeof opts.level === 'number' ? opts.level : _defaultLevel());
74
+ this._context = opts.context || {};
75
+ this._transport = typeof opts.transport === 'function' ? opts.transport : null;
76
+ this._json = !!opts.json;
77
+ this._colors = opts.colors !== undefined ? opts.colors : (process.stdout.isTTY || false);
78
+ this._timestamps = opts.timestamps !== undefined ? opts.timestamps : true;
79
+ this._stream = opts.stream || null;
80
+ this._parent = opts._parent || null;
81
+ }
82
+
83
+ /**
84
+ * Create a child logger with additional bound context.
85
+ * Child inherits all parent settings but merges extra fields
86
+ * into every log entry.
87
+ *
88
+ * @param {object} context - Extra key-value pairs for the child.
89
+ * @returns {Logger} New child logger instance.
90
+ *
91
+ * @example
92
+ * const child = logger.child({ userId: 42, action: 'checkout' });
93
+ * child.info('processing payment');
94
+ * // => { ..., userId: 42, action: 'checkout', message: 'processing payment' }
95
+ */
96
+ child(context)
97
+ {
98
+ return new Logger({
99
+ level: this._level,
100
+ context: { ...this._context, ...context },
101
+ transport: this._transport,
102
+ json: this._json,
103
+ colors: this._colors,
104
+ timestamps: this._timestamps,
105
+ stream: this._stream,
106
+ _parent: this,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Set the minimum log level.
112
+ *
113
+ * @param {string|number} level - Level name or number.
114
+ * @returns {Logger} this
115
+ */
116
+ setLevel(level)
117
+ {
118
+ this._level = typeof level === 'string' ? (LEVELS[level] ?? this._level) : level;
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Log at trace level.
124
+ * @param {string} message - Log message.
125
+ * @param {object} [fields] - Additional fields.
126
+ */
127
+ trace(message, fields) { this._log(LEVELS.trace, message, fields); }
128
+
129
+ /**
130
+ * Log at debug level.
131
+ * @param {string} message - Log message.
132
+ * @param {object} [fields] - Additional fields.
133
+ */
134
+ debug(message, fields) { this._log(LEVELS.debug, message, fields); }
135
+
136
+ /**
137
+ * Log at info level.
138
+ * @param {string} message - Log message.
139
+ * @param {object} [fields] - Additional fields.
140
+ */
141
+ info(message, fields) { this._log(LEVELS.info, message, fields); }
142
+
143
+ /**
144
+ * Log at warn level.
145
+ * @param {string} message - Log message.
146
+ * @param {object} [fields] - Additional fields.
147
+ */
148
+ warn(message, fields) { this._log(LEVELS.warn, message, fields); }
149
+
150
+ /**
151
+ * Log at error level.
152
+ * @param {string} message - Log message.
153
+ * @param {object} [fields] - Additional fields.
154
+ */
155
+ error(message, fields) { this._log(LEVELS.error, message, fields); }
156
+
157
+ /**
158
+ * Log at fatal level.
159
+ * @param {string} message - Log message.
160
+ * @param {object} [fields] - Additional fields.
161
+ */
162
+ fatal(message, fields) { this._log(LEVELS.fatal, message, fields); }
163
+
164
+ /**
165
+ * Write a log entry.
166
+ * @private
167
+ * @param {number} level - Numeric level.
168
+ * @param {string} message - Message text.
169
+ * @param {object} [fields] - Extra fields.
170
+ */
171
+ _log(level, message, fields)
172
+ {
173
+ if (level < this._level) return;
174
+
175
+ const entry = {
176
+ timestamp: new Date().toISOString(),
177
+ level: LEVEL_NAMES[level],
178
+ ...this._context,
179
+ message,
180
+ };
181
+
182
+ if (fields)
183
+ {
184
+ // Flatten error objects
185
+ if (fields instanceof Error)
186
+ {
187
+ entry.error = { message: fields.message, stack: fields.stack, code: fields.code };
188
+ }
189
+ else if (typeof fields === 'object')
190
+ {
191
+ Object.assign(entry, fields);
192
+ }
193
+ }
194
+
195
+ if (this._transport)
196
+ {
197
+ this._transport(entry);
198
+ return;
199
+ }
200
+
201
+ this._write(level, entry);
202
+ }
203
+
204
+ /**
205
+ * Write formatted output to stdout/stderr.
206
+ * @private
207
+ */
208
+ _write(level, entry)
209
+ {
210
+ const out = this._stream || (level >= LEVELS.warn ? process.stderr : process.stdout);
211
+
212
+ if (this._json)
213
+ {
214
+ out.write(JSON.stringify(entry) + '\n');
215
+ return;
216
+ }
217
+
218
+ // Pretty output
219
+ const c = this._colors;
220
+ const parts = [];
221
+
222
+ if (this._timestamps)
223
+ {
224
+ const ts = entry.timestamp.slice(11, 23); // HH:mm:ss.SSS
225
+ parts.push(c ? `${DIM}${ts}${RESET}` : ts);
226
+ }
227
+
228
+ const lvl = entry.level.toUpperCase().padEnd(5);
229
+ parts.push(c ? `${LEVEL_COLORS[level]}${lvl}${RESET}` : lvl);
230
+ parts.push(entry.message);
231
+
232
+ // Append extra context fields
233
+ const skip = new Set(['timestamp', 'level', 'message']);
234
+ const extras = {};
235
+ let hasExtras = false;
236
+ for (const k of Object.keys(entry))
237
+ {
238
+ if (!skip.has(k)) { extras[k] = entry[k]; hasExtras = true; }
239
+ }
240
+ if (hasExtras)
241
+ {
242
+ const str = JSON.stringify(extras);
243
+ parts.push(c ? `${DIM}${str}${RESET}` : str);
244
+ }
245
+
246
+ out.write(parts.join(' ') + '\n');
247
+ }
248
+ }
249
+
250
+ // -- Structured Logger Middleware ----------------------------------
251
+
252
+ /**
253
+ * Create structured request-logging middleware.
254
+ *
255
+ * Automatically logs every completed request with:
256
+ * `requestId`, `method`, `url`, `status`, `duration`, `ip`, `userAgent`,
257
+ * and `contentLength`.
258
+ *
259
+ * Also attaches `req.log` — a child logger with bound request context
260
+ * so handlers can log with full correlation.
261
+ *
262
+ * @param {object} [opts] - Configuration options.
263
+ * @param {string|number} [opts.level='info'] - Minimum log level. Default follows NODE_ENV.
264
+ * @param {'json'|'pretty'} [opts.format] - Output format. `'json'` for production, `'pretty'` for dev. Default: `'json'` in production, `'pretty'` otherwise.
265
+ * @param {Function} [opts.transport] - Custom transport `(entry) => void` for each log entry.
266
+ * @param {boolean} [opts.colors] - ANSI colors (default: TTY detection). Only applies to pretty format.
267
+ * @param {boolean} [opts.timestamps=true] - Include timestamps.
268
+ * @param {WritableStream} [opts.stream] - Output stream override.
269
+ * @param {Function} [opts.skip] - `(req, res) => boolean` — skip logging for certain requests.
270
+ * @param {Function} [opts.customFields] - `(req, res) => object` — extra fields to merge into each request log entry.
271
+ * @param {string} [opts.msg] - Custom message template. Supports placeholders: `:method`, `:url`, `:status`, `:duration`.
272
+ * @returns {Function} Middleware `(req, res, next) => void`.
273
+ *
274
+ * @example | Auto-detect Format
275
+ * app.use(structuredLogger());
276
+ *
277
+ * @example | JSON to File Transport
278
+ * const fs = require('fs');
279
+ * const logStream = fs.createWriteStream('access.log', { flags: 'a' });
280
+ * app.use(structuredLogger({ format: 'json', stream: logStream }));
281
+ *
282
+ * @example | Skip Health Checks
283
+ * app.use(structuredLogger({ skip: (req) => req.url === '/healthz' }));
284
+ */
285
+ function structuredLogger(opts = {})
286
+ {
287
+ const isProduction = (process.env.NODE_ENV || 'development') === 'production';
288
+ const useJson = opts.format === 'json' || (!opts.format && isProduction);
289
+
290
+ const logger = new Logger({
291
+ level: opts.level,
292
+ json: useJson,
293
+ colors: opts.colors,
294
+ timestamps: opts.timestamps,
295
+ stream: opts.stream,
296
+ transport: opts.transport,
297
+ });
298
+
299
+ const skip = typeof opts.skip === 'function' ? opts.skip : null;
300
+ const customFields = typeof opts.customFields === 'function' ? opts.customFields : null;
301
+ const msgTemplate = opts.msg || ':method :url :status :duration\ms';
302
+
303
+ return (req, res, next) =>
304
+ {
305
+ const start = process.hrtime.bigint();
306
+
307
+ // Attach child logger with request context to req
308
+ const reqContext = {};
309
+ if (req.id) reqContext.requestId = req.id;
310
+ req.log = logger.child(reqContext);
311
+
312
+ const raw = res.raw || res;
313
+ const onFinish = () =>
314
+ {
315
+ raw.removeListener('finish', onFinish);
316
+
317
+ if (skip && skip(req, res)) return;
318
+
319
+ const diff = process.hrtime.bigint() - start;
320
+ const durationMs = Number(diff / 1_000_000n);
321
+ const durationUs = Number(diff / 1_000n);
322
+ const status = raw.statusCode || 200;
323
+
324
+ const fields = {
325
+ requestId: req.id || undefined,
326
+ method: req.method,
327
+ url: req.originalUrl || req.url,
328
+ status,
329
+ duration: durationMs,
330
+ durationUs,
331
+ ip: req.ip || req.headers?.['x-forwarded-for'] || req.socket?.remoteAddress,
332
+ userAgent: req.headers?.['user-agent'],
333
+ contentLength: parseInt(raw.getHeader?.('content-length') || '0', 10) || undefined,
334
+ };
335
+
336
+ if (customFields)
337
+ {
338
+ try { Object.assign(fields, customFields(req, res)); }
339
+ catch (_) { /* ignore custom field errors */ }
340
+ }
341
+
342
+ // Build message
343
+ const msg = msgTemplate
344
+ .replace(':method', fields.method)
345
+ .replace(':url', fields.url)
346
+ .replace(':status', String(fields.status))
347
+ .replace(':duration', String(fields.duration));
348
+
349
+ // Choose level based on status code
350
+ const lvl = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
351
+ logger[lvl](msg, fields);
352
+ };
353
+
354
+ raw.on('finish', onFinish);
355
+ next();
356
+ };
357
+ }
358
+
359
+ module.exports = { Logger, structuredLogger, LEVELS, LEVEL_NAMES };