openvibers/openvibe.shared metrics.js

metrics.js

main 20.5 KB · 401 lines · javascript

1'use strict';
2// ═══════════════════════════════════════════════════════════════
3// openvibe-shared/metrics — Prometheus text-format metrics with no dependencies (roadmap Track O,
4// §15.19): a registry (counter, gauge, histogram), golden-signal HTTP middleware keyed by ROUTE
5// TEMPLATE, process metrics, release_info, and a /metrics handler only loopback callers reach.
6//
7// const metrics = require('openvibe-shared/metrics');
8// const m = metrics.instrument(app, { service: 'community', release: release.release });
9// // mounted: the HTTP middleware (call this before any route) and GET /metrics (loopback only)
10// const depth = m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'],
11// collect: () => db.prepare('SELECT state, COUNT(*) n FROM jobs GROUP BY state').all()
12// .map(r => ({ labels: { state: r.state }, value: r.n })) });
13//
14// Route labels are templates, never raw URLs: `req.baseUrl` (with id-like segments replaced by
15// `:id`) + `req.route.path`, or what `normalize(req)` returns. A request no route matched is
16// `unmatched`. Every metric caps its label combinations (`maxSeries`); past the cap new
17// combinations collapse into one `_overflow` series and metrics_series_overflow_total counts them.
18// ═══════════════════════════════════════════════════════════════
19
20const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
21const LABEL_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
22const DEFAULT_BUCKETS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]);
23const DEFAULT_MAX_SERIES = 500;
24const OVERFLOW = '_overflow';
25const MAX_LABEL_VALUE = 120;
26
27const escHelp = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
28const escValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
29function fmt(n) {
30 if (n === Infinity) return '+Inf';
31 if (n === -Infinity) return '-Inf';
32 if (Number.isNaN(n)) return 'NaN';
33 return String(n);
34}
35const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${escValue(values[i])}"`).join(',')}}` : '');
36
37function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
38 const metrics = new Map();
39 let overflow = null; // counter, created on first use so an untroubled registry stays quiet
40
41 function noteOverflow(name) {
42 if (!overflow) overflow = counter({ name: 'metrics_series_overflow_total', help: 'Label combinations collapsed into the _overflow series because a metric reached its series cap', labelNames: ['metric'] });
43 overflow.inc({ metric: name });
44 }
45
46 function base(kind, { name, help, labelNames = [], maxSeries: cap = maxSeries }) {
47 if (!NAME_RE.test(String(name || ''))) throw new TypeError(`metrics: invalid metric name ${name}`);
48 if (metrics.has(name)) throw new Error(`metrics: ${name} is already registered`);
49 if (!help) throw new TypeError(`metrics: ${name} needs help text`);
50 for (const l of labelNames) {
51 if (!LABEL_RE.test(l) || l.startsWith('__') || (kind === 'histogram' && l === 'le')) throw new TypeError(`metrics: invalid label ${l} on ${name}`);
52 }
53 const series = new Map(); // key -> { values: [..], ...state }
54 function key(labels = {}) {
55 const values = labelNames.map((l) => {
56 const v = labels[l];
57 return v === undefined || v === null ? '' : String(v).slice(0, MAX_LABEL_VALUE);
58 });
59 return { k: values.join('\u0000'), values };
60 }
61 function get(labels, init) {
62 let { k, values } = key(labels);
63 let s = series.get(k);
64 if (s) return s;
65 if (series.size >= cap && labelNames.length) {
66 noteOverflow(name);
67 values = labelNames.map(() => OVERFLOW);
68 k = values.join('\u0000');
69 s = series.get(k);
70 if (s) return s;
71 }
72 s = { values, ...init() };
73 series.set(k, s);
74 return s;
75 }
76 return { name, help, labelNames, series, get, kind };
77 }
78
79 function counter(opts) {
80 const m = base('counter', opts);
81 const api = {
82 name: m.name,
83 inc(labels, value = 1) {
84 if (typeof labels === 'number') { value = labels; labels = {}; }
85 if (!(value >= 0)) throw new RangeError(`metrics: ${m.name} can only increase`);
86 m.get(labels, () => ({ value: 0 })).value += value;
87 },
88 get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: 0 }).value,
89 reset: () => m.series.clear(),
90 render() {
91 const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} counter`];
92 for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
93 return out;
94 },
95 };
96 metrics.set(m.name, api);
97 return api;
98 }
99
100 // collect(): a number (no labels) or [{ labels, value }] read at scrape time, or a promise of one (a
101 // database read): the /metrics handler awaits it (up to 2 s). A throwing or rejecting collect leaves the
102 // gauge out of that scrape rather than reporting a stale or invented value; so does a promise seen by the
103 // synchronous registry.metrics().
104 function gauge(opts) {
105 const m = base('gauge', opts);
106 const collect = typeof opts.collect === 'function' ? opts.collect : null;
107 const api = {
108 name: m.name,
109 set(labels, value) {
110 if (typeof labels === 'number') { value = labels; labels = {}; }
111 m.get(labels, () => ({ value: 0 })).value = Number(value);
112 },
113 inc(labels, value = 1) {
114 if (typeof labels === 'number') { value = labels; labels = {}; }
115 m.get(labels, () => ({ value: 0 })).value += value;
116 },
117 dec(labels, value = 1) {
118 if (typeof labels === 'number') { value = labels; labels = {}; }
119 m.get(labels, () => ({ value: 0 })).value -= value;
120 },
121 get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
122 reset: () => m.series.clear(),
123 /** Collect now; → the value, or a promise of it for an async collect. */
124 collect: collect ? () => collect() : null,
125 render(given) {
126 if (collect) {
127 let got = given;
128 if (given === undefined) { try { got = collect(); } catch { return []; } }
129 if (got === undefined || got === null) return [];
130 if (typeof got.then === 'function') { got.then(() => {}, () => {}); return []; }
131 m.series.clear();
132 if (typeof got === 'number') api.set({}, got);
133 else for (const row of got) api.set(row.labels || {}, row.value);
134 }
135 const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} gauge`];
136 for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
137 return out;
138 },
139 };
140 metrics.set(m.name, api);
141 return api;
142 }
143
144 function histogram(opts) {
145 const m = base('histogram', opts);
146 const buckets = [...(opts.buckets || DEFAULT_BUCKETS)].map(Number).sort((a, b) => a - b);
147 if (!buckets.length || buckets.some((b) => !Number.isFinite(b))) throw new TypeError(`metrics: ${m.name} needs finite buckets`);
148 const init = () => ({ counts: new Array(buckets.length).fill(0), sum: 0, count: 0 });
149 const api = {
150 name: m.name,
151 buckets,
152 observe(labels, value) {
153 if (typeof labels === 'number') { value = labels; labels = {}; }
154 if (!Number.isFinite(value)) return;
155 const s = m.get(labels, init);
156 for (let i = 0; i < buckets.length; i++) if (value <= buckets[i]) s.counts[i]++;
157 s.sum += value;
158 s.count++;
159 },
160 startTimer(labels = {}) {
161 const t0 = process.hrtime.bigint();
162 return (more = {}) => {
163 const seconds = Number(process.hrtime.bigint() - t0) / 1e9;
164 api.observe({ ...labels, ...more }, seconds);
165 return seconds;
166 };
167 },
168 get: (labels) => {
169 const s = m.series.get(labelsKey(m.labelNames, labels));
170 return s ? { count: s.count, sum: s.sum, buckets: Object.fromEntries(buckets.map((b, i) => [b, s.counts[i]])) } : null;
171 },
172 reset: () => m.series.clear(),
173 render() {
174 const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} histogram`];
175 const names = [...m.labelNames, 'le'];
176 for (const s of m.series.values()) {
177 buckets.forEach((b, i) => out.push(`${m.name}_bucket${labelText(names, [...s.values, fmt(b)])} ${s.counts[i]}`));
178 out.push(`${m.name}_bucket${labelText(names, [...s.values, '+Inf'])} ${s.count}`);
179 out.push(`${m.name}_sum${labelText(m.labelNames, s.values)} ${fmt(s.sum)}`);
180 out.push(`${m.name}_count${labelText(m.labelNames, s.values)} ${s.count}`);
181 }
182 return out;
183 },
184 };
185 metrics.set(m.name, api);
186 return api;
187 }
188
189 // Callbacks run just before a scrape renders (process metrics sample themselves here).
190 const beforeScrape = [];
191 function metricsText() {
192 for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }
193 const lines = [];
194 for (const m of metrics.values()) lines.push(...m.render());
195 return lines.join('\n') + '\n';
196 }
197
198 /** The same text, after awaiting every async collect (together, each within timeoutMs). */
199 async function metricsTextAsync({ timeoutMs = 2000 } = {}) {
200 for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }
201 const list = [...metrics.values()];
202 const values = await Promise.all(list.map(async (m) => {
203 if (!m.collect) return undefined;
204 let timer;
205 try {
206 const got = m.collect();
207 if (!got || typeof got.then !== 'function') return { got };
208 return { got: await Promise.race([got, new Promise((_, no) => { timer = setTimeout(() => no(new Error('timeout')), timeoutMs); })]) };
209 } catch { return { failed: true }; } finally { clearTimeout(timer); }
210 }));
211 const lines = [];
212 list.forEach((m, i) => {
213 const v = values[i];
214 if (v && v.failed) return;
215 lines.push(...m.render(v ? (v.got === undefined ? null : v.got) : undefined));
216 });
217 return lines.join('\n') + '\n';
218 }
219
220 return {
221 counter, gauge, histogram,
222 getMetric: (name) => metrics.get(name) || null,
223 onScrape: (f) => { beforeScrape.push(f); },
224 metrics: metricsText,
225 metricsAsync: metricsTextAsync,
226 contentType: 'text/plain; version=0.0.4; charset=utf-8',
227 };
228}
229
230function labelsKey(names, labels = {}) {
231 return names.map((l) => (labels[l] === undefined || labels[l] === null ? '' : String(labels[l]).slice(0, MAX_LABEL_VALUE))).join('\u0000');
232}
233
234// ── Route templates ─────────────────────────────────────────────
235
236// A path segment that is an identifier rather than a route word: numbers, UUIDs, ULIDs, prefixed
237// subject ids (usr_…), hex digests, and long mixed tokens.
238const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26}|[a-z]{2,5}_[0-9A-Za-z]{8,}|[0-9a-f]{12,}|(?=[^/]*\d)[A-Za-z0-9_-]{16,})$/i;
239
240/** Replace id-like segments of a concrete path with `:id` (query strings are dropped). */
241function templatePath(p) {
242 const clean = String(p || '').split('?')[0];
243 return clean.split('/').map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg)).join('/') || '/';
244}
245
246/** The route label of a finished request: the matched route's template, or 'unmatched'. */
247function routeLabel(req) {
248 if (!req.route || req.route.path === undefined) return 'unmatched';
249 const rp = req.route.path;
250 // An array route ('/a', '/b') is labelled by the member that matched when it is a plain path.
251 const tail = rp instanceof RegExp ? '(regex)' : Array.isArray(rp) ? String(rp.find((p) => typeof p === 'string' && p === req.path) || rp[0]) : String(rp);
252 const base = req.baseUrl ? templatePath(req.baseUrl) : '';
253 const joined = `${base}${tail === '/' && base ? '' : tail}`;
254 return (joined || '/').slice(0, MAX_LABEL_VALUE);
255}
256
257// ── HTTP golden signals ─────────────────────────────────────────
258
259// skip(req): requests never recorded (long-lived streams such as SSE, whose "duration" is a session).
260function httpMetrics(registry, { buckets = DEFAULT_BUCKETS, normalize = null, skip = null, maxRoutes = 300 } = {}) {
261 const requests = registry.counter({ name: 'http_requests_total', help: 'HTTP requests by method, route template and status class', labelNames: ['method', 'route', 'status_class'], maxSeries: maxRoutes * 4 });
262 const duration = registry.histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration by method and route template', labelNames: ['method', 'route'], buckets, maxSeries: maxRoutes });
263 const inFlight = registry.gauge({ name: 'http_requests_in_flight', help: 'HTTP requests being served' });
264 inFlight.set(0);
265 const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
266
267 function middleware(req, res, next) {
268 try { if (skip && skip(req)) return next(); } catch { /* record it */ }
269 const t0 = process.hrtime.bigint();
270 inFlight.inc();
271 let done = false;
272 const finish = () => {
273 if (done) return;
274 done = true;
275 inFlight.dec();
276 let route;
277 try { route = normalize ? normalize(req, res) : null; } catch { route = null; }
278 if (!route) route = routeLabel(req);
279 const method = METHODS.has(req.method) ? req.method : 'OTHER';
280 // Closed before the response finished: the client went away (or a stream was cut).
281 const status = res.writableFinished || res.finished ? `${Math.floor((res.statusCode || 0) / 100)}xx` : 'aborted';
282 requests.inc({ method, route, status_class: status });
283 duration.observe({ method, route }, Number(process.hrtime.bigint() - t0) / 1e9);
284 };
285 res.once('finish', finish);
286 res.once('close', finish);
287 next();
288 }
289 return { middleware, requests, duration, inFlight };
290}
291
292// ── Process metrics ─────────────────────────────────────────────
293
294function processMetrics(registry, { eventLoopResolutionMs = 20 } = {}) {
295 let loop = null;
296 try {
297 loop = require('perf_hooks').monitorEventLoopDelay({ resolution: eventLoopResolutionMs });
298 loop.enable();
299 } catch { loop = null; }
300 const startSeconds = Math.round((Date.now() - process.uptime() * 1000) / 1000);
301 const rss = registry.gauge({ name: 'process_resident_memory_bytes', help: 'Resident set size in bytes' });
302 const heapUsed = registry.gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap in use in bytes' });
303 const heapTotal = registry.gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap allocated in bytes' });
304 const cpu = registry.gauge({ name: 'process_cpu_seconds_total', help: 'User and system CPU time spent, in seconds' });
305 const uptime = registry.gauge({ name: 'process_uptime_seconds', help: 'Seconds since the process started' });
306 registry.gauge({ name: 'process_start_time_seconds', help: 'Start time of the process since the Unix epoch, in seconds' }).set(startSeconds);
307 const lag = loop && registry.gauge({ name: 'nodejs_eventloop_lag_seconds', help: 'Event-loop delay since the previous scrape', labelNames: ['stat'] });
308 registry.onScrape(() => {
309 const mem = process.memoryUsage();
310 rss.set(mem.rss);
311 heapUsed.set(mem.heapUsed);
312 heapTotal.set(mem.heapTotal);
313 const c = process.cpuUsage();
314 cpu.set((c.user + c.system) / 1e6);
315 uptime.set(Math.round(process.uptime()));
316 if (loop && lag) {
317 // The histogram is in nanoseconds and each sample includes the sampling interval itself
318 // (an idle loop reads ~resolution), so the interval is subtracted: what is left is lag.
319 // With no samples yet it reports NaN/huge values, so nothing is claimed.
320 const ok = loop.count > 0 || loop.max > 0;
321 const lagOf = (ns) => Math.max(0, ns / 1e9 - eventLoopResolutionMs / 1000);
322 if (ok) {
323 lag.set({ stat: 'mean' }, lagOf(loop.mean));
324 lag.set({ stat: 'p99' }, lagOf(loop.percentile(99)));
325 lag.set({ stat: 'max' }, lagOf(loop.max));
326 }
327 loop.reset();
328 }
329 });
330 return { stop: () => { if (loop) loop.disable(); } };
331}
332
333/** release_info{service,release} 1 — the deployed release, from openvibe-shared/release. */
334function releaseInfo(registry, { service, release }) {
335 const g = registry.gauge({ name: 'release_info', help: 'The release this process runs (value is always 1)', labelNames: ['service', 'release'] });
336 g.set({ service: String(service || 'unknown'), release: String(release || 'unknown') }, 1);
337 return g;
338}
339
340// ── /metrics ───────────────────────────────────────────────────
341
342const LOOPBACK = /^(?:127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
343const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'true-client-ip'];
344
345/** True only for a direct loopback connection that no proxy relayed. */
346function isLoopbackDirect(req) {
347 const addr = String((req.socket && req.socket.remoteAddress) || (req.connection && req.connection.remoteAddress) || '');
348 if (!LOOPBACK.test(addr)) return false;
349 const h = req.headers || {};
350 return !PROXY_HEADERS.some((name) => h[name] !== undefined);
351}
352
353function metricsHandler(registry) {
354 return async (req, res) => {
355 if (!isLoopbackDirect(req)) {
356 res.statusCode = 404;
357 res.setHeader('Content-Type', 'application/json; charset=utf-8');
358 return res.end('{"error":"Not found"}');
359 }
360 let body;
361 try { body = registry.metricsAsync ? await registry.metricsAsync() : registry.metrics(); } catch (err) {
362 res.statusCode = 500;
363 res.setHeader('Content-Type', 'text/plain; charset=utf-8');
364 return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
365 }
366 res.statusCode = 200;
367 res.setHeader('Content-Type', registry.contentType);
368 res.setHeader('Cache-Control', 'no-store');
369 res.end(body);
370 };
371}
372
373/**
374 * Everything at once, for an Express app: a registry with process metrics and release_info, the HTTP
375 * middleware mounted first, and GET <path> (default /metrics) for loopback callers.
376 * Call it before any route or body parser is mounted.
377 */
378function instrument(app, { service, release, path = '/metrics', normalize = null, skip = null, buckets, maxRoutes, registry = createRegistry() } = {}) {
379 const http = httpMetrics(registry, { normalize, skip, buckets, maxRoutes });
380 const proc = processMetrics(registry);
381 releaseInfo(registry, { service, release });
382 app.use(http.middleware);
383 app.get(path, metricsHandler(registry));
384 // Every service instruments its app: an async handler's rejection reaches next(err) instead of ending the process.
385 require('./express-async').routeAsyncErrors(app);
386 return { registry, http, stop: proc.stop };
387}
388
389module.exports = {
390 createRegistry,
391 httpMetrics,
392 processMetrics,
393 releaseInfo,
394 metricsHandler,
395 isLoopbackDirect,
396 instrument,
397 routeLabel,
398 templatePath,
399 DEFAULT_BUCKETS,
400};
401