@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,805 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module observe/metrics
|
|
3
|
+
* @description Zero-dependency metrics registry with Prometheus-compatible
|
|
4
|
+
* text exposition format. Provides Counter, Gauge, and Histogram
|
|
5
|
+
* metric types with label support, automatic HTTP instrumentation
|
|
6
|
+
* middleware, and a handler for `/metrics` endpoints.
|
|
7
|
+
*
|
|
8
|
+
* @example | Quick Setup
|
|
9
|
+
* const { createApp, metricsMiddleware } = require('@zero-server/sdk');
|
|
10
|
+
* const app = createApp();
|
|
11
|
+
*
|
|
12
|
+
* app.use(metricsMiddleware({ registry: app.metrics() }));
|
|
13
|
+
* app.metricsEndpoint(); // GET /metrics
|
|
14
|
+
* app.health(); // GET /healthz
|
|
15
|
+
* app.ready(); // GET /readyz
|
|
16
|
+
* app.listen(3000);
|
|
17
|
+
*
|
|
18
|
+
* @example yaml | prometheus.yml
|
|
19
|
+
* scrape_configs:
|
|
20
|
+
* - job_name: 'my-app'
|
|
21
|
+
* scrape_interval: 5s
|
|
22
|
+
* static_configs:
|
|
23
|
+
* - targets: ['localhost:3000']
|
|
24
|
+
*
|
|
25
|
+
* @example | Custom Metrics
|
|
26
|
+
* const logins = app.metrics().counter({
|
|
27
|
+
* name: 'user_logins_total',
|
|
28
|
+
* help: 'Total user login attempts',
|
|
29
|
+
* labels: ['provider', 'success'],
|
|
30
|
+
* });
|
|
31
|
+
* logins.inc({ provider: 'github', success: 'true' });
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// -- Metric Types --------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prometheus-compatible Counter. Monotonically increasing.
|
|
38
|
+
* Thread-safe for single-threaded Node — no locking needed.
|
|
39
|
+
*/
|
|
40
|
+
class Counter
|
|
41
|
+
{
|
|
42
|
+
/**
|
|
43
|
+
* @constructor
|
|
44
|
+
* @param {object} opts - Counter options.
|
|
45
|
+
* @param {string} opts.name - Metric name (snake_case recommended).
|
|
46
|
+
* @param {string} opts.help - Human-readable description.
|
|
47
|
+
* @param {string[]} [opts.labels=[]] - Label names.
|
|
48
|
+
*/
|
|
49
|
+
constructor(opts)
|
|
50
|
+
{
|
|
51
|
+
this.name = opts.name;
|
|
52
|
+
this.help = opts.help || '';
|
|
53
|
+
this.type = 'counter';
|
|
54
|
+
this._labels = opts.labels || [];
|
|
55
|
+
this._values = new Map(); // labelKey => number
|
|
56
|
+
if (this._labels.length === 0) this._values.set('', 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Increment the counter.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [labels] - Label values.
|
|
63
|
+
* @param {number} [value=1] - Amount to increment (must be >= 0).
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* counter.inc(); // no labels, +1
|
|
67
|
+
* counter.inc({ method: 'GET', status: '200' }); // with labels, +1
|
|
68
|
+
* counter.inc({ method: 'POST' }, 5); // with labels, +5
|
|
69
|
+
*/
|
|
70
|
+
inc(labels, value)
|
|
71
|
+
{
|
|
72
|
+
if (typeof labels === 'number') { value = labels; labels = undefined; }
|
|
73
|
+
const v = value !== undefined ? value : 1;
|
|
74
|
+
if (v < 0) return; // counters can only go up
|
|
75
|
+
const key = _labelKey(this._labels, labels);
|
|
76
|
+
this._values.set(key, (this._values.get(key) || 0) + v);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the current value.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} [labels] - Label values.
|
|
83
|
+
* @returns {number} Current counter value.
|
|
84
|
+
*/
|
|
85
|
+
get(labels)
|
|
86
|
+
{
|
|
87
|
+
return this._values.get(_labelKey(this._labels, labels)) || 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reset the counter (all label combinations).
|
|
92
|
+
*/
|
|
93
|
+
reset()
|
|
94
|
+
{
|
|
95
|
+
this._values.clear();
|
|
96
|
+
if (this._labels.length === 0) this._values.set('', 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Serialize to Prometheus text format.
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
collect()
|
|
104
|
+
{
|
|
105
|
+
return _serialize(this.name, this.help, 'counter', this._labels, this._values);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -- Gauge ---------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Prometheus-compatible Gauge. Can go up and down.
|
|
113
|
+
*/
|
|
114
|
+
class Gauge
|
|
115
|
+
{
|
|
116
|
+
/**
|
|
117
|
+
* @constructor
|
|
118
|
+
* @param {object} opts - Gauge options.
|
|
119
|
+
* @param {string} opts.name - Metric name.
|
|
120
|
+
* @param {string} opts.help - Description.
|
|
121
|
+
* @param {string[]} [opts.labels=[]] - Label names.
|
|
122
|
+
* @param {Function} [opts.collect] - Callback invoked before collection to set dynamic values.
|
|
123
|
+
*/
|
|
124
|
+
constructor(opts)
|
|
125
|
+
{
|
|
126
|
+
this.name = opts.name;
|
|
127
|
+
this.help = opts.help || '';
|
|
128
|
+
this.type = 'gauge';
|
|
129
|
+
this._labels = opts.labels || [];
|
|
130
|
+
this._values = new Map();
|
|
131
|
+
this._collectFn = typeof opts.collect === 'function' ? opts.collect : null;
|
|
132
|
+
if (this._labels.length === 0) this._values.set('', 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Set the gauge to a specific value.
|
|
137
|
+
*
|
|
138
|
+
* @param {object|number} labels - Label values or value (if no labels).
|
|
139
|
+
* @param {number} [value] - Gauge value.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* gauge.set(42);
|
|
143
|
+
* gauge.set({ pool: 'primary' }, 10);
|
|
144
|
+
*/
|
|
145
|
+
set(labels, value)
|
|
146
|
+
{
|
|
147
|
+
if (typeof labels === 'number') { value = labels; labels = undefined; }
|
|
148
|
+
const key = _labelKey(this._labels, labels);
|
|
149
|
+
this._values.set(key, value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Increment the gauge.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} [labels] - Label values.
|
|
156
|
+
* @param {number} [value=1] - Amount.
|
|
157
|
+
*/
|
|
158
|
+
inc(labels, value)
|
|
159
|
+
{
|
|
160
|
+
if (typeof labels === 'number') { value = labels; labels = undefined; }
|
|
161
|
+
const v = value !== undefined ? value : 1;
|
|
162
|
+
const key = _labelKey(this._labels, labels);
|
|
163
|
+
this._values.set(key, (this._values.get(key) || 0) + v);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Decrement the gauge.
|
|
168
|
+
*
|
|
169
|
+
* @param {object} [labels] - Label values.
|
|
170
|
+
* @param {number} [value=1] - Amount.
|
|
171
|
+
*/
|
|
172
|
+
dec(labels, value)
|
|
173
|
+
{
|
|
174
|
+
if (typeof labels === 'number') { value = labels; labels = undefined; }
|
|
175
|
+
const v = value !== undefined ? value : 1;
|
|
176
|
+
const key = _labelKey(this._labels, labels);
|
|
177
|
+
this._values.set(key, (this._values.get(key) || 0) - v);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get the current value.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} [labels] - Label values.
|
|
184
|
+
* @returns {number} Current gauge value.
|
|
185
|
+
*/
|
|
186
|
+
get(labels)
|
|
187
|
+
{
|
|
188
|
+
return this._values.get(_labelKey(this._labels, labels)) || 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reset the gauge (all label combinations).
|
|
193
|
+
*/
|
|
194
|
+
reset()
|
|
195
|
+
{
|
|
196
|
+
this._values.clear();
|
|
197
|
+
if (this._labels.length === 0) this._values.set('', 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Serialize to Prometheus text format.
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
collect()
|
|
205
|
+
{
|
|
206
|
+
if (this._collectFn) this._collectFn(this);
|
|
207
|
+
return _serialize(this.name, this.help, 'gauge', this._labels, this._values);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -- Histogram -----------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Default histogram buckets (HTTP latency, in seconds).
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Prometheus-compatible Histogram with configurable buckets.
|
|
221
|
+
*/
|
|
222
|
+
class Histogram
|
|
223
|
+
{
|
|
224
|
+
/**
|
|
225
|
+
* @constructor
|
|
226
|
+
* @param {object} opts - Histogram options.
|
|
227
|
+
* @param {string} opts.name - Metric name.
|
|
228
|
+
* @param {string} opts.help - Description.
|
|
229
|
+
* @param {string[]} [opts.labels=[]] - Label names (must not include 'le').
|
|
230
|
+
* @param {number[]} [opts.buckets] - Upper bounds for buckets. Default: HTTP latency buckets.
|
|
231
|
+
*/
|
|
232
|
+
constructor(opts)
|
|
233
|
+
{
|
|
234
|
+
this.name = opts.name;
|
|
235
|
+
this.help = opts.help || '';
|
|
236
|
+
this.type = 'histogram';
|
|
237
|
+
this._labels = (opts.labels || []).filter(l => l !== 'le');
|
|
238
|
+
this._buckets = (opts.buckets || DEFAULT_BUCKETS).slice().sort((a, b) => a - b);
|
|
239
|
+
this._data = new Map(); // labelKey => { counts: number[], sum: number, count: number }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Observe a value.
|
|
244
|
+
*
|
|
245
|
+
* @param {object|number} labels - Label values or value (if no labels).
|
|
246
|
+
* @param {number} [value] - Observed value.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* histogram.observe(0.235); // no labels
|
|
250
|
+
* histogram.observe({ method: 'GET' }, 0.042); // with labels
|
|
251
|
+
*/
|
|
252
|
+
observe(labels, value)
|
|
253
|
+
{
|
|
254
|
+
if (typeof labels === 'number') { value = labels; labels = undefined; }
|
|
255
|
+
const key = _labelKey(this._labels, labels);
|
|
256
|
+
let data = this._data.get(key);
|
|
257
|
+
if (!data)
|
|
258
|
+
{
|
|
259
|
+
data = { counts: new Array(this._buckets.length).fill(0), sum: 0, count: 0 };
|
|
260
|
+
this._data.set(key, data);
|
|
261
|
+
}
|
|
262
|
+
data.sum += value;
|
|
263
|
+
data.count += 1;
|
|
264
|
+
for (let i = 0; i < this._buckets.length; i++)
|
|
265
|
+
{
|
|
266
|
+
if (value <= this._buckets[i]) data.counts[i]++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Start a timer that, when stopped, observes the elapsed duration in seconds.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} [labels] - Label values.
|
|
274
|
+
* @returns {Function} Stop function — call it to record the duration.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* const end = histogram.startTimer({ method: 'GET' });
|
|
278
|
+
* await doWork();
|
|
279
|
+
* end(); // records duration
|
|
280
|
+
*/
|
|
281
|
+
startTimer(labels)
|
|
282
|
+
{
|
|
283
|
+
const start = process.hrtime.bigint();
|
|
284
|
+
return () =>
|
|
285
|
+
{
|
|
286
|
+
const elapsed = Number(process.hrtime.bigint() - start) / 1e9;
|
|
287
|
+
this.observe(labels, elapsed);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get summary stats for a label combination.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} [labels] - Label values.
|
|
295
|
+
* @returns {{ sum: number, count: number }|null} Stats or null.
|
|
296
|
+
*/
|
|
297
|
+
get(labels)
|
|
298
|
+
{
|
|
299
|
+
const data = this._data.get(_labelKey(this._labels, labels));
|
|
300
|
+
return data ? { sum: data.sum, count: data.count } : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Reset all observations.
|
|
305
|
+
*/
|
|
306
|
+
reset()
|
|
307
|
+
{
|
|
308
|
+
this._data.clear();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Serialize to Prometheus text format.
|
|
313
|
+
* @returns {string}
|
|
314
|
+
*/
|
|
315
|
+
collect()
|
|
316
|
+
{
|
|
317
|
+
const lines = [];
|
|
318
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
319
|
+
lines.push(`# TYPE ${this.name} histogram`);
|
|
320
|
+
|
|
321
|
+
for (const [key, data] of this._data)
|
|
322
|
+
{
|
|
323
|
+
const labelObj = _parseLabelKey(this._labels, key);
|
|
324
|
+
|
|
325
|
+
// Bucket counts (already cumulative from observe())
|
|
326
|
+
for (let i = 0; i < this._buckets.length; i++)
|
|
327
|
+
{
|
|
328
|
+
const bucketLabels = { ...labelObj, le: String(this._buckets[i]) };
|
|
329
|
+
lines.push(`${this.name}_bucket${_formatLabels(bucketLabels)} ${data.counts[i]}`);
|
|
330
|
+
}
|
|
331
|
+
// +Inf bucket
|
|
332
|
+
const infLabels = { ...labelObj, le: '+Inf' };
|
|
333
|
+
lines.push(`${this.name}_bucket${_formatLabels(infLabels)} ${data.count}`);
|
|
334
|
+
|
|
335
|
+
// Sum and count
|
|
336
|
+
const baseLabels = Object.keys(labelObj).length > 0 ? _formatLabels(labelObj) : '';
|
|
337
|
+
lines.push(`${this.name}_sum${baseLabels} ${data.sum}`);
|
|
338
|
+
lines.push(`${this.name}_count${baseLabels} ${data.count}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return lines.join('\n');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// -- Metrics Registry ----------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Central metrics registry. Manages all metric instances and
|
|
349
|
+
* serialises them to Prometheus text exposition format.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* const registry = new MetricsRegistry({ prefix: 'myapp_' });
|
|
353
|
+
* const counter = registry.counter({ name: 'requests_total', help: 'Total requests' });
|
|
354
|
+
* counter.inc();
|
|
355
|
+
* console.log(registry.metrics());
|
|
356
|
+
*/
|
|
357
|
+
class MetricsRegistry
|
|
358
|
+
{
|
|
359
|
+
/**
|
|
360
|
+
* @constructor
|
|
361
|
+
* @param {object} [opts] - Registry options.
|
|
362
|
+
* @param {string} [opts.prefix=''] - Global prefix for all metric names.
|
|
363
|
+
*/
|
|
364
|
+
constructor(opts = {})
|
|
365
|
+
{
|
|
366
|
+
this._prefix = opts.prefix || '';
|
|
367
|
+
/** @type {Map<string, Counter|Gauge|Histogram>} */
|
|
368
|
+
this._metrics = new Map();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Create and register a Counter.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} opts - Counter options.
|
|
375
|
+
* @param {string} opts.name - Metric name.
|
|
376
|
+
* @param {string} opts.help - Description.
|
|
377
|
+
* @param {string[]} [opts.labels] - Label names.
|
|
378
|
+
* @returns {Counter} The registered counter.
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* const c = registry.counter({ name: 'http_requests_total', help: 'Total HTTP requests', labels: ['method', 'status'] });
|
|
382
|
+
*/
|
|
383
|
+
counter(opts)
|
|
384
|
+
{
|
|
385
|
+
const name = this._prefix + opts.name;
|
|
386
|
+
if (this._metrics.has(name)) return this._metrics.get(name);
|
|
387
|
+
const c = new Counter({ ...opts, name });
|
|
388
|
+
this._metrics.set(name, c);
|
|
389
|
+
return c;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Create and register a Gauge.
|
|
394
|
+
*
|
|
395
|
+
* @param {object} opts - Gauge options.
|
|
396
|
+
* @param {string} opts.name - Metric name.
|
|
397
|
+
* @param {string} opts.help - Description.
|
|
398
|
+
* @param {string[]} [opts.labels] - Label names.
|
|
399
|
+
* @param {Function} [opts.collect] - Dynamic collection callback.
|
|
400
|
+
* @returns {Gauge} The registered gauge.
|
|
401
|
+
*
|
|
402
|
+
* @example
|
|
403
|
+
* const g = registry.gauge({ name: 'process_memory_bytes', help: 'Memory usage',
|
|
404
|
+
* collect: (gauge) => gauge.set(process.memoryUsage().heapUsed) });
|
|
405
|
+
*/
|
|
406
|
+
gauge(opts)
|
|
407
|
+
{
|
|
408
|
+
const name = this._prefix + opts.name;
|
|
409
|
+
if (this._metrics.has(name)) return this._metrics.get(name);
|
|
410
|
+
const g = new Gauge({ ...opts, name });
|
|
411
|
+
this._metrics.set(name, g);
|
|
412
|
+
return g;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Create and register a Histogram.
|
|
417
|
+
*
|
|
418
|
+
* @param {object} opts - Histogram options.
|
|
419
|
+
* @param {string} opts.name - Metric name.
|
|
420
|
+
* @param {string} opts.help - Description.
|
|
421
|
+
* @param {string[]} [opts.labels] - Label names.
|
|
422
|
+
* @param {number[]} [opts.buckets] - Bucket boundaries.
|
|
423
|
+
* @returns {Histogram} The registered histogram.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* const h = registry.histogram({ name: 'http_request_duration_seconds', help: 'Request duration', labels: ['method', 'route'] });
|
|
427
|
+
*/
|
|
428
|
+
histogram(opts)
|
|
429
|
+
{
|
|
430
|
+
const name = this._prefix + opts.name;
|
|
431
|
+
if (this._metrics.has(name)) return this._metrics.get(name);
|
|
432
|
+
const h = new Histogram({ ...opts, name });
|
|
433
|
+
this._metrics.set(name, h);
|
|
434
|
+
return h;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Get a registered metric by name.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} name - Full metric name (including prefix).
|
|
441
|
+
* @returns {Counter|Gauge|Histogram|undefined}
|
|
442
|
+
*/
|
|
443
|
+
getMetric(name)
|
|
444
|
+
{
|
|
445
|
+
return this._metrics.get(this._prefix + name) || this._metrics.get(name);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Remove a registered metric.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} name - Metric name.
|
|
452
|
+
* @returns {boolean} True if removed.
|
|
453
|
+
*/
|
|
454
|
+
removeMetric(name)
|
|
455
|
+
{
|
|
456
|
+
return this._metrics.delete(this._prefix + name) || this._metrics.delete(name);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Remove all registered metrics.
|
|
461
|
+
*/
|
|
462
|
+
clear()
|
|
463
|
+
{
|
|
464
|
+
this._metrics.clear();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Reset all metric values without removing registrations.
|
|
469
|
+
*/
|
|
470
|
+
resetAll()
|
|
471
|
+
{
|
|
472
|
+
for (const m of this._metrics.values()) m.reset();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Serialize all metrics to Prometheus text exposition format.
|
|
477
|
+
*
|
|
478
|
+
* @returns {string} Multi-line Prometheus-compatible text.
|
|
479
|
+
*
|
|
480
|
+
* @example
|
|
481
|
+
* const text = registry.metrics();
|
|
482
|
+
* // # HELP http_requests_total Total HTTP requests
|
|
483
|
+
* // # TYPE http_requests_total counter
|
|
484
|
+
* // http_requests_total{method="GET",status="200"} 42
|
|
485
|
+
*/
|
|
486
|
+
metrics()
|
|
487
|
+
{
|
|
488
|
+
const parts = [];
|
|
489
|
+
for (const m of this._metrics.values())
|
|
490
|
+
{
|
|
491
|
+
const text = m.collect();
|
|
492
|
+
if (text) parts.push(text);
|
|
493
|
+
}
|
|
494
|
+
return parts.join('\n\n') + '\n';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Return all metrics as a plain object (for JSON export or IPC transfer).
|
|
499
|
+
*
|
|
500
|
+
* @returns {object} Serialisable snapshot of all metrics.
|
|
501
|
+
*/
|
|
502
|
+
toJSON()
|
|
503
|
+
{
|
|
504
|
+
const result = {};
|
|
505
|
+
for (const [name, m] of this._metrics)
|
|
506
|
+
{
|
|
507
|
+
if (m.type === 'histogram')
|
|
508
|
+
{
|
|
509
|
+
const entries = {};
|
|
510
|
+
for (const [key, data] of m._data)
|
|
511
|
+
{
|
|
512
|
+
entries[key || '_'] = { sum: data.sum, count: data.count, counts: data.counts.slice() };
|
|
513
|
+
}
|
|
514
|
+
result[name] = { type: m.type, entries };
|
|
515
|
+
}
|
|
516
|
+
else
|
|
517
|
+
{
|
|
518
|
+
const entries = {};
|
|
519
|
+
for (const [key, val] of m._values) entries[key || '_'] = val;
|
|
520
|
+
result[name] = { type: m.type, entries };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Merge a metrics snapshot (from `toJSON()`) into this registry.
|
|
528
|
+
* Used for aggregating worker metrics on the primary process.
|
|
529
|
+
*
|
|
530
|
+
* @param {object} snapshot - Object from `toJSON()`.
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* // On primary
|
|
534
|
+
* mgr.onMessage('metrics:report', (data) => {
|
|
535
|
+
* primaryRegistry.merge(data);
|
|
536
|
+
* });
|
|
537
|
+
*/
|
|
538
|
+
merge(snapshot)
|
|
539
|
+
{
|
|
540
|
+
for (const [name, data] of Object.entries(snapshot))
|
|
541
|
+
{
|
|
542
|
+
const metric = this._metrics.get(name);
|
|
543
|
+
if (!metric) continue;
|
|
544
|
+
|
|
545
|
+
if (data.type === 'histogram' && metric.type === 'histogram')
|
|
546
|
+
{
|
|
547
|
+
for (const [key, entry] of Object.entries(data.entries))
|
|
548
|
+
{
|
|
549
|
+
const resolvedKey = key === '_' ? '' : key;
|
|
550
|
+
let existing = metric._data.get(resolvedKey);
|
|
551
|
+
if (!existing)
|
|
552
|
+
{
|
|
553
|
+
existing = { counts: new Array(metric._buckets.length).fill(0), sum: 0, count: 0 };
|
|
554
|
+
metric._data.set(resolvedKey, existing);
|
|
555
|
+
}
|
|
556
|
+
existing.sum += entry.sum;
|
|
557
|
+
existing.count += entry.count;
|
|
558
|
+
for (let i = 0; i < existing.counts.length && i < entry.counts.length; i++)
|
|
559
|
+
{
|
|
560
|
+
existing.counts[i] += entry.counts[i];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else if (data.type === 'counter' && metric.type === 'counter')
|
|
565
|
+
{
|
|
566
|
+
for (const [key, val] of Object.entries(data.entries))
|
|
567
|
+
{
|
|
568
|
+
const resolvedKey = key === '_' ? '' : key;
|
|
569
|
+
metric._values.set(resolvedKey, (metric._values.get(resolvedKey) || 0) + val);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else if (data.type === 'gauge' && metric.type === 'gauge')
|
|
573
|
+
{
|
|
574
|
+
// For gauges from workers, we sum them (active connections across workers)
|
|
575
|
+
for (const [key, val] of Object.entries(data.entries))
|
|
576
|
+
{
|
|
577
|
+
const resolvedKey = key === '_' ? '' : key;
|
|
578
|
+
metric._values.set(resolvedKey, (metric._values.get(resolvedKey) || 0) + val);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// -- Default HTTP Metrics ------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Create the standard set of HTTP metrics on a registry.
|
|
589
|
+
*
|
|
590
|
+
* @param {MetricsRegistry} registry - Target registry.
|
|
591
|
+
* @returns {object} Object with all default metric instances.
|
|
592
|
+
*/
|
|
593
|
+
function createDefaultMetrics(registry)
|
|
594
|
+
{
|
|
595
|
+
return {
|
|
596
|
+
httpRequestsTotal: registry.counter({
|
|
597
|
+
name: 'http_requests_total',
|
|
598
|
+
help: 'Total HTTP requests processed',
|
|
599
|
+
labels: ['method', 'route', 'status'],
|
|
600
|
+
}),
|
|
601
|
+
httpRequestDuration: registry.histogram({
|
|
602
|
+
name: 'http_request_duration_seconds',
|
|
603
|
+
help: 'HTTP request duration in seconds',
|
|
604
|
+
labels: ['method', 'route'],
|
|
605
|
+
}),
|
|
606
|
+
httpActiveConnections: registry.gauge({
|
|
607
|
+
name: 'http_active_connections',
|
|
608
|
+
help: 'Number of active HTTP connections',
|
|
609
|
+
}),
|
|
610
|
+
wsConnectionsActive: registry.gauge({
|
|
611
|
+
name: 'ws_connections_active',
|
|
612
|
+
help: 'Number of active WebSocket connections',
|
|
613
|
+
}),
|
|
614
|
+
sseStreamsActive: registry.gauge({
|
|
615
|
+
name: 'sse_streams_active',
|
|
616
|
+
help: 'Number of active SSE streams',
|
|
617
|
+
}),
|
|
618
|
+
grpcCallsActive: registry.gauge({
|
|
619
|
+
name: 'grpc_calls_active',
|
|
620
|
+
help: 'Number of active gRPC calls',
|
|
621
|
+
}),
|
|
622
|
+
grpcCallsTotal: registry.counter({
|
|
623
|
+
name: 'grpc_calls_total',
|
|
624
|
+
help: 'Total gRPC calls processed',
|
|
625
|
+
labels: ['method', 'status'],
|
|
626
|
+
}),
|
|
627
|
+
grpcCallDuration: registry.histogram({
|
|
628
|
+
name: 'grpc_call_duration_seconds',
|
|
629
|
+
help: 'gRPC call duration in seconds',
|
|
630
|
+
labels: ['method'],
|
|
631
|
+
}),
|
|
632
|
+
dbQueryDuration: registry.histogram({
|
|
633
|
+
name: 'db_query_duration_seconds',
|
|
634
|
+
help: 'Database query duration in seconds',
|
|
635
|
+
labels: ['adapter', 'operation'],
|
|
636
|
+
}),
|
|
637
|
+
dbPoolActive: registry.gauge({
|
|
638
|
+
name: 'db_pool_active',
|
|
639
|
+
help: 'Active database pool connections',
|
|
640
|
+
}),
|
|
641
|
+
dbPoolIdle: registry.gauge({
|
|
642
|
+
name: 'db_pool_idle',
|
|
643
|
+
help: 'Idle database pool connections',
|
|
644
|
+
}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// -- Metrics Middleware --------------------------------------------
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Create HTTP metrics collection middleware.
|
|
652
|
+
* Automatically tracks `http_requests_total`, `http_request_duration_seconds`,
|
|
653
|
+
* and `http_active_connections`.
|
|
654
|
+
*
|
|
655
|
+
* @param {object} [opts] - Options.
|
|
656
|
+
* @param {MetricsRegistry} [opts.registry] - Metrics registry. Creates a new one if not provided.
|
|
657
|
+
* @param {Function} [opts.routeLabel] - `(req) => string` — extract route label for metrics. Default: `req.route || req.url`.
|
|
658
|
+
* @param {Function} [opts.skip] - `(req) => boolean` — skip metrics for certain requests.
|
|
659
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
660
|
+
*
|
|
661
|
+
* @example
|
|
662
|
+
* // Use with app.metrics() to share the registry
|
|
663
|
+
* app.use(metricsMiddleware({ registry: app.metrics() }));
|
|
664
|
+
* app.metricsEndpoint(); // serves the same registry at GET /metrics
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* // Skip health check routes and use custom route labels
|
|
668
|
+
* app.use(metricsMiddleware({
|
|
669
|
+
* registry: app.metrics(),
|
|
670
|
+
* skip: (req) => req.url === '/healthz',
|
|
671
|
+
* routeLabel: (req) => req.route || req.url.split('?')[0],
|
|
672
|
+
* }));
|
|
673
|
+
*/
|
|
674
|
+
function metricsMiddleware(opts = {})
|
|
675
|
+
{
|
|
676
|
+
const registry = opts.registry || new MetricsRegistry();
|
|
677
|
+
const defaults = createDefaultMetrics(registry);
|
|
678
|
+
const routeLabel = typeof opts.routeLabel === 'function' ? opts.routeLabel : null;
|
|
679
|
+
const skip = typeof opts.skip === 'function' ? opts.skip : null;
|
|
680
|
+
|
|
681
|
+
return (req, res, next) =>
|
|
682
|
+
{
|
|
683
|
+
if (skip && skip(req)) return next();
|
|
684
|
+
|
|
685
|
+
const start = process.hrtime.bigint();
|
|
686
|
+
defaults.httpActiveConnections.inc();
|
|
687
|
+
|
|
688
|
+
const raw = res.raw || res;
|
|
689
|
+
const onFinish = () =>
|
|
690
|
+
{
|
|
691
|
+
raw.removeListener('finish', onFinish);
|
|
692
|
+
defaults.httpActiveConnections.dec();
|
|
693
|
+
|
|
694
|
+
const elapsed = Number(process.hrtime.bigint() - start) / 1e9;
|
|
695
|
+
const status = String(raw.statusCode || 200);
|
|
696
|
+
const method = req.method;
|
|
697
|
+
const route = routeLabel ? routeLabel(req) : (req.route || req.url?.split('?')[0] || '/');
|
|
698
|
+
|
|
699
|
+
defaults.httpRequestsTotal.inc({ method, route, status });
|
|
700
|
+
defaults.httpRequestDuration.observe({ method, route }, elapsed);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
raw.on('finish', onFinish);
|
|
704
|
+
next();
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Create a metrics endpoint handler.
|
|
710
|
+
* Returns Prometheus text exposition format.
|
|
711
|
+
*
|
|
712
|
+
* @param {MetricsRegistry} registry - The registry to expose.
|
|
713
|
+
* @returns {Function} Route handler `(req, res) => void`.
|
|
714
|
+
*
|
|
715
|
+
* @example
|
|
716
|
+
* app.get('/metrics', metricsEndpoint(registry));
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
* // Or use the app shorthand which creates the handler automatically
|
|
720
|
+
* app.metricsEndpoint(); // GET /metrics
|
|
721
|
+
* app.metricsEndpoint('/prometheus'); // GET /prometheus
|
|
722
|
+
*/
|
|
723
|
+
function metricsEndpoint(registry)
|
|
724
|
+
{
|
|
725
|
+
return (req, res) =>
|
|
726
|
+
{
|
|
727
|
+
const body = registry.metrics();
|
|
728
|
+
res.set('Content-Type', 'text/plain; version=0.04; charset=utf-8');
|
|
729
|
+
res.set('Cache-Control', 'no-store');
|
|
730
|
+
res.send(body);
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// -- Helpers -------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Create a label key string from label names and values.
|
|
738
|
+
* @private
|
|
739
|
+
*/
|
|
740
|
+
function _labelKey(labelNames, labels)
|
|
741
|
+
{
|
|
742
|
+
if (!labelNames || labelNames.length === 0 || !labels) return '';
|
|
743
|
+
return labelNames.map(n => String(labels[n] ?? '')).join('\x00');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Parse a label key back to an object.
|
|
748
|
+
* @private
|
|
749
|
+
*/
|
|
750
|
+
function _parseLabelKey(labelNames, key)
|
|
751
|
+
{
|
|
752
|
+
if (!key || labelNames.length === 0) return {};
|
|
753
|
+
const parts = key.split('\x00');
|
|
754
|
+
const obj = {};
|
|
755
|
+
for (let i = 0; i < labelNames.length; i++)
|
|
756
|
+
{
|
|
757
|
+
if (parts[i]) obj[labelNames[i]] = parts[i];
|
|
758
|
+
}
|
|
759
|
+
return obj;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Format a labels object as Prometheus label string.
|
|
764
|
+
* @private
|
|
765
|
+
*/
|
|
766
|
+
function _formatLabels(obj)
|
|
767
|
+
{
|
|
768
|
+
const keys = Object.keys(obj);
|
|
769
|
+
if (keys.length === 0) return '';
|
|
770
|
+
const pairs = keys.map(k =>
|
|
771
|
+
{
|
|
772
|
+
const v = String(obj[k]).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
773
|
+
return `${k}="${v}"`;
|
|
774
|
+
});
|
|
775
|
+
return `{${pairs.join(',')}}`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Serialize a counter/gauge metric to Prometheus text.
|
|
780
|
+
* @private
|
|
781
|
+
*/
|
|
782
|
+
function _serialize(name, help, type, labelNames, values)
|
|
783
|
+
{
|
|
784
|
+
const lines = [];
|
|
785
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
786
|
+
lines.push(`# TYPE ${name} ${type}`);
|
|
787
|
+
for (const [key, val] of values)
|
|
788
|
+
{
|
|
789
|
+
const labelObj = _parseLabelKey(labelNames, key);
|
|
790
|
+
const labels = _formatLabels(labelObj);
|
|
791
|
+
lines.push(`${name}${labels} ${val}`);
|
|
792
|
+
}
|
|
793
|
+
return lines.join('\n');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
module.exports = {
|
|
797
|
+
Counter,
|
|
798
|
+
Gauge,
|
|
799
|
+
Histogram,
|
|
800
|
+
MetricsRegistry,
|
|
801
|
+
DEFAULT_BUCKETS,
|
|
802
|
+
createDefaultMetrics,
|
|
803
|
+
metricsMiddleware,
|
|
804
|
+
metricsEndpoint,
|
|
805
|
+
};
|