openvibers/openvibe.community Commits

Observability (Track O): GET /metrics (loopback only) with HTTP golden signals by route template, process metrics and release_info; /api/ready built on openvibe-shared/ready (db required; Network key, Live or Media optional -> degraded) instead of an unconditional {ready:true}; nginx refuses /metrics; openvibe-shared v1.3.0

OP OpenVibers committed 23 Sept 2026 · b492398a57 · 8 files, +190 −10


Changes

ddiff --git a/README.md b/README.md
iindex b6e673a..1e52383 100644
---- a/README.md
++++ b/README.md
@@@ -109,7 +109,8 @@ API and machine endpoints:
| `/api/v1/spaces/*`, `/api/v1/posts/*` | The forum — see [Forum](#forum-spaces-threads-posts) |
| `/api/v1/pulse/*` | Pulse — see [Pulse](#pulse) |
| `/api/v1/relay/*` | Discord relay administration (staff) — see [Discord relay](#discord-relay) |
--| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
++| `GET /api/health`, `GET /api/ready` | Liveness / readiness (see below) |
++| `GET /metrics` | Prometheus text for direct loopback callers only (404 through nginx) |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
| `GET /auth/logout` | Clears session, sets `ov_sso_hint=guest`, honours `?next=` |
@@@ -118,6 +119,18 @@ API and machine endpoints:
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
++**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
++when the required `db` check fails (a real query on Community's SQLite). `network_jwks` (the
++Network signing key; without it nobody can sign in or write as a signed-in viewer or service),
++`live` (in `PASTES_AUTHORITY=live`: paste pages and `/api/pastes` read through Live) and `media`
++(in `community` mode: screenshot and file uploads) are optional: a failure keeps the site ready
++(comments, forum, Pulse and public reads still work) and is listed in `degraded` with
++`status: "degraded"`. Every check reports `status`, `required`, `latency_ms` and `checked_at`.
++Until Track O this route returned `{ "ready": true }` unconditionally. `GET /metrics` serves HTTP
++golden signals by route template (`http_requests_total{method,route,status_class}`,
++`http_request_duration_seconds`, `http_requests_in_flight`), process metrics and
++`release_info`; content counts (pastes, comments, threads) are deliberately not metrics.
++
Cookies are host-only for `openvibe.community`: `ov_token` (24 h access JWT, JS-readable so
the shared navbar can use it), `ov_refresh` (httpOnly, `/auth`), `ov_sso_hint`
(`account`/`guest`, 1 year, JS-readable — the navbar only tries a silent sign-in when it says
ddiff --git a/deploy/nginx/openvibe.community.conf b/deploy/nginx/openvibe.community.conf
iindex d473d45..ae1a3d6 100644
---- a/deploy/nginx/openvibe.community.conf
++++ b/deploy/nginx/openvibe.community.conf
@@@ -109,9 +109,14 @@ server {
proxy_pass http://127.0.0.1:4200;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4200/metrics). Node also
++ # refuses any request carrying X-Forwarded-For, which every location above sets. ──
++ location = /metrics { return 404; }
++
# ── Everything else (server-rendered pages) ─────────────
location / {
proxy_pass http://127.0.0.1:4200;
ddiff --git a/package-lock.json b/package-lock.json
iindex ed27ec0..2a33227 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"engines": {
"node": ">=20.0.0"
@@@ -1072,9 +1072,8 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-XYDP1I0f44T+ImWqcErW3P1bBVzsPZ8bgjYBk9QVJP6zDiK5/GYj5ynQhh5GhB9FhxvK92rFu7Lx1ZohZDNSkA==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex f646ff0..aab2df9 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.2.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"engines": {
"node": ">=20.0.0"
ddiff --git a/server/app.js b/server/app.js
iindex 23db5b3..412b143 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -11,7 +11,7 @@
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
-- * GET /p/:slug/download GET /api/health, /api/ready
++ * GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
* GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /s … spaces, threads, posts (forum/routes.js)
@@@ -64,6 +64,12 @@ function createApp(opts = {}) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
++ // What this server runs (ADR-016); the shared navbar's release-watch polls it.
++ const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
++ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
++ // direct loopback callers only (Track O). Request metrics only: no content counts.
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
++ app.locals.metrics = metrics.registry;
app.use(helmet({
contentSecurityPolicy: {
@@@ -158,10 +164,11 @@ function createApp(opts = {}) {
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
-- // What this server runs (ADR-016); the shared navbar's release-watch polls it.
-- const release = require('openvibe-shared/release').createRelease({ service: 'community', root: require('path').join(__dirname, '..') });
app.get('/release.json', release.handler);
-- app.get('/api/ready', (_req, res) => res.json({ ready: true }));
++ // Readiness reports what is actually served: 503 only without the database; Network key,
++ // Live (live mode) and Media (community mode) failures degrade (server/observability.js).
++ const readiness = require('./observability').createCommunityReadiness({ db, auth, config, relay, release: release.release, fetchImpl: opts.fetchImpl });
++ app.get('/api/ready', readiness.handler);
// ── Static assets (content-hashed ?v= → immutable) ───────
app.use(express.static(PUBLIC_DIR, {
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..026c24a
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).
++ *
++ * db required a real query on Community's SQLite (the schema is there and answers)
++ * network_jwks optional the Network signing key has loaded. Without it public pages and reads
++ * still work, but nobody can sign in or write as a signed-in viewer or
++ * service (those answer 503), so it degrades rather than fails
++ * live optional in PASTES_AUTHORITY=live mode: Live answers /api/health (paste pages and
++ * /api/pastes read through it; comments, forum and Pulse do not)
++ * media optional in PASTES_AUTHORITY=community mode: Media answers /healthz
++ * (screenshot and file uploads; paste text is local)
++ *
++ * Request metrics come from openvibe-shared/metrics in app.js. Content counts (pastes, comments,
++ * threads) are deliberately not metrics.
++ */
++const { createReadiness } = require('openvibe-shared/ready');
++
++const PING_TTL_MS = 15_000;
++
++function probe(url, fetchImpl) {
++ return async () => {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
++ try { await res.body?.cancel(); } catch { /* not needed */ }
++ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };
++ };
++}
++
++function createCommunityReadiness({ db, auth, config, relay = null, release = null, fetchImpl = globalThis.fetch }) {
++ const checks = [
++ {
++ name: 'db', required: true,
++ check: () => {
++ const n = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n;
++ return n > 0 || 'database has no tables';
++ },
++ },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ if (auth.client.publicKey) return true;
++ // Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.
++ auth.ensureKey().catch(() => {});
++ return 'Network signing key not loaded yet: sign-in and signed-in writes are unavailable';
++ },
++ },
++ ];
++ if (config.pastesAuthority === 'community') {
++ checks.push({ name: 'media', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.mediaInternalUrl}/healthz`, fetchImpl) });
++ } else {
++ checks.push({ name: 'live', required: false, cacheMs: PING_TTL_MS, timeoutMs: 2500, check: probe(`${config.liveInternalUrl}/api/health`, fetchImpl) });
++ }
++ return createReadiness({
++ service: 'community',
++ release,
++ checks,
++ details: (body) => {
++ const out = { pastes_authority: config.pastesAuthority };
++ if (relay && relay.enabled && body.checks.db.status === 'ok') {
++ const q = {};
++ for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status').all()) q[r.status] = r.n;
++ out.discord_relay = { enabled: true, deliveries: q };
++ }
++ return out;
++ },
++ });
++}
++
++module.exports = { createCommunityReadiness };
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
iindex 792047e..e9f7d6d 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -16,6 +16,7 @@ function start({ publicPem, issuer }) {
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });
if (req.method !== 'POST' || req.url !== '/api/v1/community/files') return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
ddiff --git a/test/observability.test.js b/test/observability.test.js
nnew file mode 100644
iindex 0000000..f38e14c
---- /dev/null
++++ b/test/observability.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++/** Track O: GET /metrics is loopback-only with route templates; /api/ready reports what is actually served. */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/app');
++
++function raw(base, path, headers = {}) {
++ return new Promise((resolve, reject) => http.get(base + path, { headers }, (res) => {
++ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));
++ }).on('error', reject));
++}
++
++(async () => {
++ const t = await boot();
++ // Let the boot-time key fetch land.
++ await t.app.locals.auth.ensureKey();
++
++ await check('live mode: ready, with db required and Network key and Live optional', async () => {
++ const r = await t.get('/api/ready');
++ assert.strictEqual(r.status, 200, r.text);
++ const b = r.json();
++ assert.strictEqual(b.ready, true);
++ assert.strictEqual(b.status, 'ready');
++ assert.strictEqual(b.service, 'community');
++ assert.strictEqual(b.pastes_authority, 'live');
++ assert.deepStrictEqual(Object.keys(b.checks), ['db', 'network_jwks', 'live']);
++ assert.strictEqual(b.checks.db.required, true);
++ assert.strictEqual(b.checks.network_jwks.required, false);
++ assert.strictEqual(b.checks.live.required, false);
++ for (const c of Object.values(b.checks)) {
++ assert.strictEqual(c.status, 'ok');
++ assert.strictEqual(typeof c.latency_ms, 'number');
++ assert.ok(Date.parse(c.checked_at));
++ }
++ });
++
++ await check('/metrics: 404 through a proxy, route templates (never slugs or ids) to a loopback caller', async () => {
++ await t.get('/api/pastes/abc123secretslug');
++ await t.get('/p/another-secret-slug');
++ await t.get('/no/such/page/99999');
++ const proxied = await raw(t.base, '/metrics', { 'X-Forwarded-For': '203.0.113.9' });
++ assert.strictEqual(proxied.status, 404);
++ const m = await raw(t.base, '/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.ok(/http_requests_total\{method="GET",route="\/api\/ready",status_class="2xx"\} 1/.test(m.body), m.body.slice(0, 2000));
++ assert.ok(m.body.includes('route="/p/:slug"'));
++ const labels = m.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');
++ assert.ok(!/secret|99999/.test(labels), 'no raw paths in labels');
++ assert.ok(/release_info\{service="community",release="[0-9a-f]{7,12}"\} 1/.test(m.body));
++ assert.ok(!/pastes_total|comments_total|threads_total/.test(m.body), 'no content counts');
++ });
++
++ await check('Live down: still ready (comments, forum and Pulse are served), live degraded', async () => {
++ await t.live.close();
++ await new Promise((r) => setTimeout(r, 20));
++ // The Live probe is cached for 15 s; a fresh app instance sees the outage immediately.
++ const { createCommunityReadiness } = require('../server/observability');
++ const rd = createCommunityReadiness({ db: t.app.locals.db, auth: t.app.locals.auth, config: t.app.locals.config });
++ const b = await rd.run();
++ assert.strictEqual(b.ready, true);
++ assert.strictEqual(b.status, 'degraded');
++ assert.deepStrictEqual(b.degraded, ['live']);
++ });
++
++ await check('Network key not loaded: degraded, not down', async () => {
++ const { createCommunityReadiness } = require('../server/observability');
++ const auth = { client: { publicKey: null }, ensureKey: async () => null };
++ const b = await createCommunityReadiness({ db: t.app.locals.db, auth, config: { ...t.app.locals.config, pastesAuthority: 'community', mediaInternalUrl: t.media.url } }).run();
++ assert.strictEqual(b.ready, true);
++ assert.deepStrictEqual(b.degraded, ['network_jwks']);
++ assert.strictEqual(b.checks.media.status, 'ok', 'community mode probes Media instead of Live');
++ assert.match(b.checks.network_jwks.error, /not loaded/);
++ });
++
++ await check('database unusable: 503 not_ready with db failed', async () => {
++ t.app.locals.db.close();
++ const r = await t.get('/api/ready');
++ assert.strictEqual(r.status, 503);
++ const b = r.json();
++ assert.strictEqual(b.ready, false);
++ assert.deepStrictEqual(b.failed, ['db']);
++ });
++
++ await t.close().catch(() => {});
++ done();
++})();