openvibers/openvibe.shared Commits

Shared 1.26.0: async gauges. A gauge's collect() may return a promise (a database read, now that services are on PostgreSQL, ADR-035). The /metrics handler awaits every async collect together through the new registry.metricsAsync({ timeoutMs = 2000 }); one that rejects or runs past its budget is left out of that scrape, never reported stale, as a throwing one already was. The synchronous registry.metrics() is unchanged and leaves an async gauge out. The handler is an async function; its callers need no change. npm test 39/39.

OP OpenVibers committed 28 Sept 2026 · 49ce9d65db · 7 files, +62 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2813825..5764bc1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.26.0 — 2026-09-28
++
++**Async gauges** (ADR-035: services on PostgreSQL read their counts asynchronously). A gauge's `collect()` may return a promise. The `/metrics` handler (`metricsHandler`, and the `instrument()` middleware that uses it) now calls the new `registry.metricsAsync({ timeoutMs = 2000 })`. It awaits every async collect together. A collect that rejects or runs past its budget is left out of that scrape and is never reported stale, just as a throwing one already was. The synchronous `registry.metrics()` is unchanged, and it leaves an async gauge out. The handler is now an async function; Express and plain `http` callers need no change. `test/metrics.test.js` covers async, rejecting and slow collects. This replaces the "one scrape behind" workaround the first PostgreSQL services used.
++
## 1.25.0 — 2026-09-28
**`openvibe-shared/test-runner`: no fake green in `npm test`** (roadmap WS-Q task 7). Every service carried its own copy of `test/run.js`, and each counted a test file that exited 0 as passed, including one that printed `…: skipped (no ffmpeg)` and checked nothing. The runner is now one module: `require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs, pad, hide, env })` (`run()` returns the results for a caller that wants them).
ddiff --git a/README.md b/README.md
iindex 3e7e0da..31b66f7 100644
---- a/README.md
++++ b/README.md
@@@ -57,6 +57,8 @@ const m = require('openvibe-shared/metrics').instrument(app, { service: 'communi
// before any route: HTTP golden signals by route template, process metrics, release_info,
// and GET /metrics for direct loopback callers only (404 through a proxy)
m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
++// collect may be async (a database read): /metrics awaits it, up to 2 s, and leaves out one that fails.
++m.registry.gauge({ name: 'docs', help: 'Documents', collect: async () => (await db.one(sql`SELECT count(*)::int AS n FROM docs`)).n });
const ready = require('openvibe-shared/ready').createReadiness({
service: 'community', release: release.release,
ddiff --git a/STATUS.json b/STATUS.json
iindex 0ccc080..d0dc678 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.25.0",
++ "version": "1.26.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/metrics.js b/metrics.js
iindex 51e311e..8157220 100644
---- a/metrics.js
++++ b/metrics.js
@@@ -97,8 +97,10 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
return api;
}
-- // collect(): a number (no labels) or [{ labels, value }] read at scrape time. A throwing collect
-- // leaves the gauge out of that scrape rather than reporting a stale or invented value.
++ // collect(): a number (no labels) or [{ labels, value }] read at scrape time, or a promise of one (a
++ // database read): the /metrics handler awaits it (up to 2 s). A throwing or rejecting collect leaves the
++ // gauge out of that scrape rather than reporting a stale or invented value; so does a promise seen by the
++ // synchronous registry.metrics().
function gauge(opts) {
const m = base('gauge', opts);
const collect = typeof opts.collect === 'function' ? opts.collect : null;
@@@ -118,11 +120,14 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
},
get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
reset: () => m.series.clear(),
-- render() {
++ /** Collect now; → the value, or a promise of it for an async collect. */
++ collect: collect ? () => collect() : null,
++ render(given) {
if (collect) {
-- let got;
-- try { got = collect(); } catch { return []; }
++ let got = given;
++ if (given === undefined) { try { got = collect(); } catch { return []; } }
if (got === undefined || got === null) return [];
++ if (typeof got.then === 'function') { got.then(() => {}, () => {}); return []; }
m.series.clear();
if (typeof got === 'number') api.set({}, got);
else for (const row of got) api.set(row.labels || {}, row.value);
@@@ -190,11 +195,34 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
return lines.join('\n') + '\n';
}
++ /** The same text, after awaiting every async collect (together, each within timeoutMs). */
++ async function metricsTextAsync({ timeoutMs = 2000 } = {}) {
++ for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }
++ const list = [...metrics.values()];
++ const values = await Promise.all(list.map(async (m) => {
++ if (!m.collect) return undefined;
++ let timer;
++ try {
++ const got = m.collect();
++ if (!got || typeof got.then !== 'function') return { got };
++ return { got: await Promise.race([got, new Promise((_, no) => { timer = setTimeout(() => no(new Error('timeout')), timeoutMs); })]) };
++ } catch { return { failed: true }; } finally { clearTimeout(timer); }
++ }));
++ const lines = [];
++ list.forEach((m, i) => {
++ const v = values[i];
++ if (v && v.failed) return;
++ lines.push(...m.render(v ? (v.got === undefined ? null : v.got) : undefined));
++ });
++ return lines.join('\n') + '\n';
++ }
++
return {
counter, gauge, histogram,
getMetric: (name) => metrics.get(name) || null,
onScrape: (f) => { beforeScrape.push(f); },
metrics: metricsText,
++ metricsAsync: metricsTextAsync,
contentType: 'text/plain; version=0.0.4; charset=utf-8',
};
}
@@@ -323,14 +351,14 @@ function isLoopbackDirect(req) {
}
function metricsHandler(registry) {
-- return (req, res) => {
++ return async (req, res) => {
if (!isLoopbackDirect(req)) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
return res.end('{"error":"Not found"}');
}
let body;
-- try { body = registry.metrics(); } catch (err) {
++ try { body = registry.metricsAsync ? await registry.metricsAsync() : registry.metrics(); } catch (err) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
ddiff --git a/package-lock.json b/package-lock.json
iindex e74198a..db280bf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.25.0",
++ "version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.25.0",
++ "version": "1.26.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 44a79eb..7244273 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.25.0",
++ "version": "1.26.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/test/metrics.test.js b/test/metrics.test.js
iindex 1dbe0b8..ab6ddba 100644
---- a/test/metrics.test.js
++++ b/test/metrics.test.js
@@@ -130,8 +130,24 @@ const metrics = require('../metrics');
const fakeRes = () => { const o = { headers: {}, statusCode: 0, body: '' }; o.setHeader = (k, v) => { o.headers[k] = v; }; o.end = (b) => { o.body = b; }; return o; };
let res = fakeRes(); handler(req('192.0.2.1'), res);
assert.strictEqual(res.statusCode, 404);
-- res = fakeRes(); handler(req('127.0.0.1'), res);
++ res = fakeRes(); await handler(req('127.0.0.1'), res);
assert.strictEqual(res.statusCode, 200);
assert.ok(res.body.includes('release_info{service="s",release="r"} 1'));
++
++ // Async collect (a database read): the handler awaits it; the synchronous metrics() leaves it out; a rejecting
++ // or slow one is left out of that scrape, never reported stale.
++ reg.gauge({ name: 'db_rows', help: 'Rows', labelNames: ['table'], collect: async () => [{ labels: { table: 'docs' }, value: 42 }] });
++ reg.gauge({ name: 'db_scalar', help: 'Scalar', collect: () => Promise.resolve(7) });
++ reg.gauge({ name: 'db_broken', help: 'Broken', collect: async () => { throw new Error('down'); } });
++ reg.gauge({ name: 'db_slow', help: 'Slow', collect: () => new Promise((r) => setTimeout(() => r(1), 200)) });
++ assert.ok(!reg.metrics().includes('db_rows{'), 'the synchronous text leaves an async gauge out');
++ res = fakeRes(); await handler(req('127.0.0.1'), res);
++ assert.ok(res.body.includes('db_rows{table="docs"} 42\n'), res.body);
++ assert.ok(res.body.includes('db_scalar 7\n'));
++ assert.ok(!res.body.includes('db_broken'));
++ assert.ok(res.body.includes('db_slow 1\n'), 'within the 2 s budget');
++ const fast = await reg.metricsAsync({ timeoutMs: 20 });
++ assert.ok(!fast.includes('db_slow'), 'past its budget: left out');
++ assert.ok(fast.includes('db_rows{table="docs"} 42\n'));
console.log('metrics: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });