@zero-server/observe 0.9.1 → 0.9.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/LICENSE +21 -21
- package/index.js +22 -22
- package/lib/observe/health.js +326 -0
- package/lib/observe/index.js +50 -0
- package/lib/observe/logger.js +359 -0
- package/lib/observe/metrics.js +805 -0
- package/lib/observe/tracing.js +592 -0
- package/package.json +9 -3
|
@@ -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 };
|