@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.
@@ -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
+ };