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.
Changes
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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).@@@ -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,@@@ -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)",@@@ -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`);@@@ -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"@@@ -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": {@@@ -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); });
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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).
**`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).
@@ -57,6 +57,8 @@ const m = require('openvibe-shared/metrics').instrument(app, { service: 'communi
@@ -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,
// 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)
// 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 });
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({
const ready = require('openvibe-shared/ready').createReadiness({
service: 'community', release: release.release,
service: 'community', release: release.release,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.25.0",
+ "version": "1.26.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -97,8 +97,10 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
@@ -97,8 +97,10 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
return api;
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) {
function gauge(opts) {
const m = base('gauge', opts);
const m = base('gauge', opts);
const collect = typeof opts.collect === 'function' ? opts.collect : null;
const collect = typeof opts.collect === 'function' ? opts.collect : null;
@@ -118,11 +120,14 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
@@ -118,11 +120,14 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
},
},
get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
reset: () => m.series.clear(),
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) {
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 (got === undefined || got === null) return [];
+ if (typeof got.then === 'function') { got.then(() => {}, () => {}); return []; }
m.series.clear();
m.series.clear();
if (typeof got === 'number') api.set({}, got);
if (typeof got === 'number') api.set({}, got);
else for (const row of got) api.set(row.labels || {}, row.value);
else for (const row of got) api.set(row.labels || {}, row.value);
@@ -190,11 +195,34 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
@@ -190,11 +195,34 @@ function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
return lines.join('\n') + '\n';
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 {
return {
counter, gauge, histogram,
counter, gauge, histogram,
getMetric: (name) => metrics.get(name) || null,
getMetric: (name) => metrics.get(name) || null,
onScrape: (f) => { beforeScrape.push(f); },
onScrape: (f) => { beforeScrape.push(f); },
metrics: metricsText,
metrics: metricsText,
+ metricsAsync: metricsTextAsync,
contentType: 'text/plain; version=0.0.4; charset=utf-8',
contentType: 'text/plain; version=0.0.4; charset=utf-8',
};
};
}
}
@@ -323,14 +351,14 @@ function isLoopbackDirect(req) {
@@ -323,14 +351,14 @@ function isLoopbackDirect(req) {
}
}
function metricsHandler(registry) {
function metricsHandler(registry) {
- return (req, res) => {
+ return async (req, res) => {
if (!isLoopbackDirect(req)) {
if (!isLoopbackDirect(req)) {
res.statusCode = 404;
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
return res.end('{"error":"Not found"}');
return res.end('{"error":"Not found"}');
}
}
let body;
let body;
- try { body = registry.metrics(); } catch (err) {
+ try { body = registry.metricsAsync ? await registry.metricsAsync() : registry.metrics(); } catch (err) {
res.statusCode = 500;
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.25.0",
+ "version": "1.26.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.25.0",
+ "version": "1.26.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -130,8 +130,24 @@ const metrics = require('../metrics');
@@ -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; };
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);
let res = fakeRes(); handler(req('192.0.2.1'), res);
assert.strictEqual(res.statusCode, 404);
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.strictEqual(res.statusCode, 200);
assert.ok(res.body.includes('release_info{service="s",release="r"} 1'));
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');
console.log('metrics: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });