openvibers/openvibe.sources Commits
Track O: /metrics for direct loopback callers and a truthful /api/ready (openvibe-shared v1.3.0)
Changes
@@@ -33,9 +33,14 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/oenv `/etc/openvibe/sources.env`, unit [deploy/systemd/openvibe-sources.service](deploy/systemd/openvibe-sources.service),store `/var/lib/openvibe-sources/sources.db`, nginx [deploy/nginx/sources.openvibe.network.conf](deploy/nginx/sources.openvibe.network.conf).--`GET /api/health` is liveness. `GET /api/ready` is 200 when the database answers, the Network key--has loaded and the scheduler runs; it reports source counts by health status, runs in flight and--the outbox backlog.++`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the++database fails; a Network key that has not loaded and a fetcher that is off, stopped or behind (a++source due for more than 15 minutes) degrade it. It reports source counts by health status, runs in++flight and the outbox backlog. `GET /metrics` (openvibe-shared/metrics) answers direct loopback++callers only: golden signals by route template, `sources_sources{status}`, `sources_items{state}`,++the fetch queue (`sources_fetch_due`, `sources_fetch_oldest_wait_seconds`,++`sources_fetch_in_flight`), `sources_last_fetch_timestamp_seconds` and++`sources_last_success_timestamp_seconds`.## The registry@@@ -6,6 +6,7 @@#### The Node service (port 4720, systemd unit openvibe-sources) serves:## /api/health, /api/ready++## /metrics Prometheus text, answered only to direct loopback callers (never proxied)## /api/v1/* service-to-service API (every route needs a service token). Services on this## host call 127.0.0.1:4720 directly, so the public vhost refuses it; open the## location below only when a service on another host needs it.@@@ -48,6 +49,9 @@ server {location = /api/health { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /api/ready { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }++ # Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).++ location = /metrics { return 404; }++# ── Service API: host-local only (see header) ───────────location /api/ {return 403;@@@ -13,7 +13,8 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"devDependencies": {"ajv": "^8.20.0",@@@ -174,6 +175,12 @@"ieee754": "^1.1.13"}},++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },"node_modules/bytes": {"version": "3.1.2","resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",@@@ -341,6 +348,15 @@"node": ">= 0.4"}},++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },"node_modules/ee-first": {"version": "1.1.1","resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",@@@ -720,6 +736,97 @@"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==","license": "MIT"},++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },"node_modules/math-intrinsics": {"version": "1.1.0","resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",@@@ -898,6 +1005,15 @@"node": ">=20"}},++ "node_modules/openvibe-shared": {++ "version": "1.3.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -27,7 +27,8 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {"node": ">=22.0.0"@@@ -1,7 +1,11 @@'use strict';--/** Express app: request context, the v1 API, health/readiness. */++/** Express app: request context, the v1 API, health/readiness, metrics. */++const path = require('path');const express = require('express');const { http } = require('openvibe-contracts');++const { instrument } = require('openvibe-shared/metrics');++const { createRelease } = require('openvibe-shared/release');++const { createSourcesReadiness, registerSourcesGauges } = require('./observability');const { sourcesRouter } = require('./api/sources');const { itemsRouter } = require('./api/items');const pkg = require('../package.json');@@@ -10,6 +14,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,const app = express();app.disable('x-powered-by');app.set('trust proxy', 'loopback');++ const release = createRelease({ service: 'sources', root: path.join(__dirname, '..') });++ // HTTP golden signals by route template, process metrics, release_info and the Sources gauges;++ // GET /metrics answers direct loopback callers only (Track O).++ const metrics = instrument(app, { service: 'sources', release: release.release });++ registerSourcesGauges(metrics.registry, { db, sources: registry, ingest, outbox, now });app.use(http.middleware());app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');@@@ -22,27 +31,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });});-- app.get('/api/ready', (_req, res) => {-- let dbOk = false;-- try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }-- const checks = { db: dbOk, key: keys.loaded(), worker: !config.worker.enabled || scheduler.running() };-- const ready = Object.values(checks).every(Boolean);-- let sources = null;-- if (dbOk) {-- sources = {};-- for (const r of registry.all()) {-- const s = registry.health(r).status;-- sources[s] = (sources[s] || 0) + 1;-- }-- }-- res.status(ready ? 200 : 503).json({-- status: ready ? 'ready' : 'not_ready',-- checks,-- sources,-- runs_in_flight: ingest.inflight().length,-- outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,-- });-- });++ // Readiness (openvibe-shared/ready): 503 only when the database fails; the Network key and the++ // fetcher (worker running, queue keeping up) are optional and degrade it (see observability.js).++ const readiness = createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release: release.release });++ app.get('/api/ready', readiness.handler);++ app.get('/release.json', release.handler);app.use(sourcesRouter({ db, registry, ingest, auth }));app.use(itemsRouter({ db, registry, items, auth, relay, now }));@@@ -56,7 +49,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)','GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)','POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',-- 'GET /api/health, /api/ready',++ 'GET /api/health, /api/ready, /release.json','','Source: https://github.com/OpenVibers/OpenVibe.Sources','',@@@ -74,6 +67,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,return http.sendProblem(res, 500, 'sources.internal', { detail: 'internal error', ctx: req.ov });});++ app.locals.metrics = metrics;return app;}nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Track O: truthful readiness for GET /api/ready and the Sources gauges on GET /metrics++ * (openvibe-shared/ready and openvibe-shared/metrics).++ *++ * db required a real query on the source registry: without it nothing is served or fetched++ * network_jwks optional the Network signing key has loaded. Without it no service token can be++ * verified (the API answers 503), but the fetcher keeps ingesting, so it++ * degrades rather than fails++ * fetcher optional the ingestion worker is on and running, and its queue keeps up: no enabled++ * source has waited longer than OVERDUE_MS. Without it the API still++ * serves what was fetched, but nothing new arrives++ *++ * Gauges: sources by health status, items (current and removed), the queue (due sources, runs in++ * flight), the time of the last finished fetch and of the last successful one, and the outbox backlog.++ */++const { createReadiness } = require('openvibe-shared/ready');++++const OVERDUE_MS = 15 * 60 * 1000;++const STATUSES = ['healthy', 'stale', 'failing', 'never_fetched', 'disabled', 'manual'];++++function readers(db, { startedAt }) {++ // A source waits from when it became due, when it was last changed (a new or re-enabled source++ // is due at once, from next_due_at 0) or when this process started, whichever is latest.++ const due = db.prepare(`SELECT COUNT(*) AS n, MIN(MAX(next_due_at, not_before, updated_at)) AS oldest FROM sources++ WHERE enabled = 1 AND type != 'manual' AND next_due_at <= @now AND not_before <= @now`);++ const lastRun = db.prepare('SELECT finished_at FROM fetch_runs ORDER BY rid DESC LIMIT 1');++ const lastSuccess = db.prepare('SELECT MAX(last_success_at) AS t FROM sources');++ const items = db.prepare('SELECT COUNT(*) AS n, COUNT(removed_at) AS removed FROM items');++ return {++ /** Enabled fetched sources whose turn has come, and how long the oldest has waited. */++ queue(t) {++ const r = due.get({ now: t });++ return { due: r.n, oldest_wait_ms: r.n ? Math.max(0, t - Math.max(r.oldest, startedAt)) : 0 };++ },++ lastFetchAt: () => { const r = lastRun.get(); return r ? r.finished_at : null; },++ lastSuccessAt: () => lastSuccess.get().t,++ items: () => { const r = items.get(); return { current: r.n - r.removed, removed: r.removed }; },++ };++}++++function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release = null }) {++ const read = readers(db, { startedAt: now() });++ const iso = (v) => (v == null ? null : new Date(v).toISOString());++ return createReadiness({++ service: 'sources',++ release,++ checks: [++ { name: 'db', required: true, check: () => { db.prepare('SELECT COUNT(*) AS n FROM sources').get(); return true; } },++ { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: no service token can be verified' },++ {++ name: 'fetcher', required: false,++ check: () => {++ const q = read.queue(now());++ const detail = {++ enabled: config.worker.enabled, running: scheduler.running(), in_flight: ingest.inflight().length,++ max_concurrent: config.worker.maxConcurrent, due: q.due, oldest_wait_ms: q.oldest_wait_ms,++ last_fetch_at: iso(read.lastFetchAt()),++ };++ if (!config.worker.enabled) return { ok: false, error: 'the ingestion worker is off (SOURCES_WORKER=off): nothing is fetched', detail };++ if (!scheduler.running()) return { ok: false, error: 'the ingestion worker is not running', detail };++ if (q.oldest_wait_ms > OVERDUE_MS) return { ok: false, error: `the fetch queue is behind: ${q.due} source(s) due, the oldest for ${Math.round(q.oldest_wait_ms / 60000)} min`, detail };++ return { ok: true, detail };++ },++ },++ ],++ details: (body) => {++ const dbOk = body.checks.db.status === 'ok';++ let sources = null;++ if (dbOk) {++ sources = {};++ for (const r of registry.all()) {++ const s = registry.health(r).status;++ sources[s] = (sources[s] || 0) + 1;++ }++ }++ return {++ sources,++ runs_in_flight: ingest.inflight().length,++ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,++ };++ },++ });++}++++/** Sources gauges on the openvibe-shared/metrics registry. */++function registerSourcesGauges(registry, { db, sources, ingest, outbox, now }) {++ const read = readers(db, { startedAt: now() });++ const seconds = (ms) => (ms == null ? null : ms / 1000);++ registry.gauge({++ name: 'sources_sources', help: 'Registered sources by health status', labelNames: ['status'],++ collect: () => {++ const n = Object.fromEntries(STATUSES.map((s) => [s, 0]));++ for (const r of sources.all()) { const s = sources.health(r).status; n[s] = (n[s] || 0) + 1; }++ return Object.entries(n).map(([status, value]) => ({ labels: { status }, value }));++ },++ });++ registry.gauge({++ name: 'sources_items', help: 'Items held, current and removed', labelNames: ['state'],++ collect: () => Object.entries(read.items()).map(([state, value]) => ({ labels: { state }, value })),++ });++ registry.gauge({ name: 'sources_fetch_due', help: 'Enabled sources whose next fetch is due now (the fetch queue)', collect: () => read.queue(now()).due });++ registry.gauge({ name: 'sources_fetch_oldest_wait_seconds', help: 'How long the longest-waiting due source has waited', collect: () => read.queue(now()).oldest_wait_ms / 1000 });++ registry.gauge({ name: 'sources_fetch_in_flight', help: 'Fetch runs in progress', collect: () => ingest.inflight().length });++ // Left out until there is a fetch: a timestamp of 0 would read as "1970", not "never".++ registry.gauge({ name: 'sources_last_fetch_timestamp_seconds', help: 'When the last fetch run finished (any outcome), Unix seconds', collect: () => seconds(read.lastFetchAt()) });++ registry.gauge({ name: 'sources_last_success_timestamp_seconds', help: 'When a source was last fetched successfully, Unix seconds', collect: () => seconds(read.lastSuccessAt()) });++ registry.gauge({ name: 'sources_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.pending() });++}++++module.exports = { createSourcesReadiness, registerSourcesGauges, OVERDUE_MS };@@@ -147,7 +147,7 @@ t('health lists what needs attention', async () => {assert.ok(h.body.attention.some(a => a.key === 'never' && a.status === 'never_fetched'));const ready = await api('GET', '/api/ready');assert.strictEqual(ready.status, 200);-- assert.strictEqual(ready.body.checks.db, true);++ assert.strictEqual(ready.body.checks.db.status, 'ok');});t('done', async () => { await web.close(); await svc.stop(); });@@@ -40,7 +40,7 @@ function tmpDir() {}/** Boot Sources; the scheduler is off unless worker: 'on'. Loopback is allowlisted for the stubs. */--async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient } = {}) {++async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now } = {}) {const dir = tmpDir();const config = load({NODE_ENV: 'test',@@@ -54,7 +54,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient } = {})SOURCES_TICK_MS: '50',...env,});-- const h = await start({ config, log: silent, lookupImpl, tokenClient });++ const h = await start({ config, log: silent, lookupImpl, tokenClient, ...(now ? { now } : {}) });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, async stop() { await h.close(); } };}nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++// Track O: GET /metrics answers direct loopback callers only, labels requests by route template and++// carries the Sources gauges (sources by status, items, the fetch queue, last fetch); /api/ready is++// 503 when the database fails, and a fetcher that is off, stopped or behind degrades it.++const assert = require('assert');++const nodeHttp = require('http');++const { boot, request, serviceToken, site, sourceDef, rss, suite } = require('./helpers');++const { OVERDUE_MS } = require('../server/observability');++++const t = suite('observability');++const ADMIN = serviceToken('network', ['sources.source.manage']);++const READER = serviceToken('news', ['sources.source.read', 'sources.item.read']);++let svc;++let web;++++function get(base, p, headers = {}) {++ return new Promise((resolve, reject) => nodeHttp.get(base + p, { headers }, (res) => {++ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));++ }).on('error', reject));++}++++t('boot (worker off) and fetch one feed of three items by hand', async () => {++ svc = await boot();++ await svc.keyLoaded;++ web = await site({++ '/robots.txt': () => ({ status: 404 }),++ '/feed.xml': () => ({ body: rss([1, 2, 3].map(i => ({ guid: `g${i}`, title: `Item ${i}`, link: `https://example.org/${i}` }))) }),++ });++ const c = await request(svc.base, 'POST', '/api/v1/sources', { token: ADMIN, body: sourceDef({ key: 'feed-obs', endpoints: [`${web.origin}/feed.xml`] }) });++ assert.strictEqual(c.status, 201, c.text);++ const f = await request(svc.base, 'POST', '/api/v1/sources/feed-obs/fetch', { token: ADMIN });++ assert.deepStrictEqual(f.body.runs.map(r => [r.state, r.items.created]), [['ok', 3]]);++});++++t('/api/ready: db is the only required check; a worker that is off degrades it (still 200)', async () => {++ const r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.ready, true);++ assert.strictEqual(r.body.status, 'degraded');++ assert.strictEqual(r.body.service, 'sources');++ assert.deepStrictEqual(Object.keys(r.body.checks), ['db', 'network_jwks', 'fetcher']);++ assert.deepStrictEqual(r.body.degraded, ['fetcher']);++ for (const [name, c] of Object.entries(r.body.checks)) {++ assert.strictEqual(c.required, name === 'db', name);++ assert.strictEqual(typeof c.latency_ms, 'number');++ assert.ok(Date.parse(c.checked_at));++ }++ assert.match(r.body.checks.fetcher.error, /SOURCES_WORKER=off/);++ assert.ok(Date.parse(r.body.checks.fetcher.detail.last_fetch_at));++ assert.deepStrictEqual(r.body.sources, { healthy: 1 });++});++++t('/metrics: 404 through a proxy; route templates and Sources gauges direct', async () => {++ await request(svc.base, 'GET', '/api/v1/sources/feed-obs', { token: READER });++ for (const hdr of [{ 'X-Forwarded-For': '203.0.113.7' }, { 'X-Real-IP': '203.0.113.7' }, { 'CF-Connecting-IP': '203.0.113.7' }]) {++ const m = await get(svc.base, '/metrics', hdr);++ assert.strictEqual(m.status, 404, JSON.stringify(hdr));++ assert.ok(!m.body.includes('sources_items'));++ }++ const m = await get(svc.base, '/metrics');++ assert.strictEqual(m.status, 200);++ const text = m.body;++ assert.ok(/http_requests_total\{method="POST",route="\/api\/v1\/sources\/:key\/fetch",status_class="2xx"\} 1\n/.test(text), 'route template');++ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/sources\/:key",status_class="2xx"\} 1\n/.test(text));++ assert.ok(!/route="[^"]*feed-obs/.test(text), 'no source key in any label');++ assert.ok(/\nprocess_resident_memory_bytes \d+\n/.test(text));++ assert.ok(/release_info\{service="sources",release="[^"]+"\} 1\n/.test(text));++ assert.ok(/sources_sources\{status="healthy"\} 1\n/.test(text));++ assert.ok(/sources_sources\{status="failing"\} 0\n/.test(text));++ assert.ok(/sources_items\{state="current"\} 3\n/.test(text));++ assert.ok(/sources_items\{state="removed"\} 0\n/.test(text));++ assert.ok(/\nsources_fetch_due 0\n/.test(text));++ assert.ok(/\nsources_fetch_in_flight 0\n/.test(text));++ const last = text.match(/\nsources_last_fetch_timestamp_seconds ([\d.]+)\n/);++ assert.ok(last && Math.abs(Number(last[1]) - Date.now() / 1000) < 60, 'last fetch time in Unix seconds');++ assert.ok(/\nsources_last_success_timestamp_seconds [\d.]+\n/.test(text));++ assert.ok(/\nsources_outbox_pending \d+\n/.test(text));++});++++t('a broken database makes the service unready (503); /metrics still answers', async () => {++ svc.db.close();++ const r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual(r.body.ready, false);++ assert.ok(r.body.failed.includes('db'), r.text);++ assert.strictEqual(r.body.sources, null);++ const m = await get(svc.base, '/metrics');++ assert.strictEqual(m.status, 200);++ assert.ok(!/sources_items\{/.test(m.body), 'a gauge that cannot be read is left out, not invented');++ try { await svc.stop(); } catch { /* the database is already closed */ }++});++++t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degrades it', async () => {++ const clock = { offset: 0 };++ // No run slot, so nothing due is ever started; the clock moves forward by hand.++ svc = await boot({ worker: 'on', env: { SOURCES_MAX_CONCURRENT: '0' }, now: () => Date.now() + clock.offset });++ let r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.body.status, 'ready', r.text);++ assert.strictEqual(r.body.checks.fetcher.detail.running, true);++ await request(svc.base, 'POST', '/api/v1/sources', { token: ADMIN, body: sourceDef({ key: 'feed-late', endpoints: [`${web.origin}/feed.xml`] }) });++ r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.body.status, 'ready', 'a source that just became due is not late');++ assert.strictEqual(r.body.checks.fetcher.detail.due, 1);++++ clock.offset = OVERDUE_MS + 60_000;++ r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.degraded, ['fetcher']);++ assert.match(r.body.checks.fetcher.error, /the fetch queue is behind: 1 source\(s\) due, the oldest for 16 min/);++ const text = (await get(svc.base, '/metrics')).body;++ assert.ok(/\nsources_fetch_due 1\n/.test(text));++ const wait = Number(text.match(/\nsources_fetch_oldest_wait_seconds ([\d.]+)\n/)[1]);++ assert.ok(wait >= OVERDUE_MS / 1000, String(wait));++++ clock.offset = 0;++ await svc.scheduler.stop();++ r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.body.degraded, ['fetcher']);++ assert.strictEqual(r.body.checks.fetcher.error, 'the ingestion worker is not running');++ await svc.stop();++ await web.close();++});++++t.run();
@@ -33,9 +33,14 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/o
@@ -33,9 +33,14 @@ Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/o
env `/etc/openvibe/sources.env`, unit [deploy/systemd/openvibe-sources.service](deploy/systemd/openvibe-sources.service),
env `/etc/openvibe/sources.env`, unit [deploy/systemd/openvibe-sources.service](deploy/systemd/openvibe-sources.service),
store `/var/lib/openvibe-sources/sources.db`, nginx [deploy/nginx/sources.openvibe.network.conf](deploy/nginx/sources.openvibe.network.conf).
store `/var/lib/openvibe-sources/sources.db`, nginx [deploy/nginx/sources.openvibe.network.conf](deploy/nginx/sources.openvibe.network.conf).
-`GET /api/health` is liveness. `GET /api/ready` is 200 when the database answers, the Network key
-has loaded and the scheduler runs; it reports source counts by health status, runs in flight and
-the outbox backlog.
+`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the
+database fails; a Network key that has not loaded and a fetcher that is off, stopped or behind (a
+source due for more than 15 minutes) degrade it. It reports source counts by health status, runs in
+flight and the outbox backlog. `GET /metrics` (openvibe-shared/metrics) answers direct loopback
+callers only: golden signals by route template, `sources_sources{status}`, `sources_items{state}`,
+the fetch queue (`sources_fetch_due`, `sources_fetch_oldest_wait_seconds`,
+`sources_fetch_in_flight`), `sources_last_fetch_timestamp_seconds` and
+`sources_last_success_timestamp_seconds`.
## The registry
## The registry
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
##
##
## The Node service (port 4720, systemd unit openvibe-sources) serves:
## The Node service (port 4720, systemd unit openvibe-sources) serves:
## /api/health, /api/ready
## /api/health, /api/ready
+## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
## /api/v1/* service-to-service API (every route needs a service token). Services on this
## /api/v1/* service-to-service API (every route needs a service token). Services on this
## host call 127.0.0.1:4720 directly, so the public vhost refuses it; open the
## host call 127.0.0.1:4720 directly, so the public vhost refuses it; open the
## location below only when a service on another host needs it.
## location below only when a service on another host needs it.
@@ -48,6 +49,9 @@ server {
@@ -48,6 +49,9 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/health { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/ready { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /api/ready { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
+ # Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
+ location = /metrics { return 404; }
+
# ── Service API: host-local only (see header) ───────────
# ── Service API: host-local only (see header) ───────────
location /api/ {
location /api/ {
return 403;
return 403;
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"devDependencies": {
"devDependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
@@ -174,6 +175,12 @@
@@ -174,6 +175,12 @@
"ieee754": "^1.1.13"
"ieee754": "^1.1.13"
}
}
},
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/bytes": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -341,6 +348,15 @@
@@ -341,6 +348,15 @@
"node": ">= 0.4"
"node": ">= 0.4"
}
}
},
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"node_modules/ee-first": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -720,6 +736,97 @@
@@ -720,6 +736,97 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"node_modules/math-intrinsics": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -898,6 +1005,15 @@
@@ -898,6 +1005,15 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-shared": {
+ "version": "1.3.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
+ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -27,7 +27,8 @@
@@ -27,7 +27,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.13.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -1,7 +1,11 @@
@@ -1,7 +1,11 @@
'use strict';
'use strict';
-/** Express app: request context, the v1 API, health/readiness. */
+/** Express app: request context, the v1 API, health/readiness, metrics. */
+const path = require('path');
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
+const { instrument } = require('openvibe-shared/metrics');
+const { createRelease } = require('openvibe-shared/release');
+const { createSourcesReadiness, registerSourcesGauges } = require('./observability');
const { sourcesRouter } = require('./api/sources');
const { sourcesRouter } = require('./api/sources');
const { itemsRouter } = require('./api/items');
const { itemsRouter } = require('./api/items');
const pkg = require('../package.json');
const pkg = require('../package.json');
@@ -10,6 +14,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -10,6 +14,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
+ const release = createRelease({ service: 'sources', root: path.join(__dirname, '..') });
+ // HTTP golden signals by route template, process metrics, release_info and the Sources gauges;
+ // GET /metrics answers direct loopback callers only (Track O).
+ const metrics = instrument(app, { service: 'sources', release: release.release });
+ registerSourcesGauges(metrics.registry, { db, sources: registry, ingest, outbox, now });
app.use(http.middleware());
app.use(http.middleware());
app.use((req, res, next) => {
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -22,27 +31,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -22,27 +31,11 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });
});
});
- app.get('/api/ready', (_req, res) => {
- let dbOk = false;
- try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }
- const checks = { db: dbOk, key: keys.loaded(), worker: !config.worker.enabled || scheduler.running() };
- const ready = Object.values(checks).every(Boolean);
- let sources = null;
- if (dbOk) {
- sources = {};
- for (const r of registry.all()) {
- const s = registry.health(r).status;
- sources[s] = (sources[s] || 0) + 1;
- }
- }
- res.status(ready ? 200 : 503).json({
- status: ready ? 'ready' : 'not_ready',
- checks,
- sources,
- runs_in_flight: ingest.inflight().length,
- outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
- });
- });
+ // Readiness (openvibe-shared/ready): 503 only when the database fails; the Network key and the
+ // fetcher (worker running, queue keeping up) are optional and degrade it (see observability.js).
+ const readiness = createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release: release.release });
+ app.get('/api/ready', readiness.handler);
+ app.get('/release.json', release.handler);
app.use(sourcesRouter({ db, registry, ingest, auth }));
app.use(sourcesRouter({ db, registry, ingest, auth }));
app.use(itemsRouter({ db, registry, items, auth, relay, now }));
app.use(itemsRouter({ db, registry, items, auth, relay, now }));
@@ -56,7 +49,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -56,7 +49,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',
'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',
'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',
'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',
'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',
'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',
- 'GET /api/health, /api/ready',
+ 'GET /api/health, /api/ready, /release.json',
'',
'',
'Source: https://github.com/OpenVibers/OpenVibe.Sources',
'Source: https://github.com/OpenVibers/OpenVibe.Sources',
'',
'',
@@ -74,6 +67,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -74,6 +67,7 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
return http.sendProblem(res, 500, 'sources.internal', { detail: 'internal error', ctx: req.ov });
return http.sendProblem(res, 500, 'sources.internal', { detail: 'internal error', ctx: req.ov });
});
});
+ app.locals.metrics = metrics;
return app;
return app;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Track O: truthful readiness for GET /api/ready and the Sources gauges on GET /metrics
+ * (openvibe-shared/ready and openvibe-shared/metrics).
+ *
+ * db required a real query on the source registry: without it nothing is served or fetched
+ * network_jwks optional the Network signing key has loaded. Without it no service token can be
+ * verified (the API answers 503), but the fetcher keeps ingesting, so it
+ * degrades rather than fails
+ * fetcher optional the ingestion worker is on and running, and its queue keeps up: no enabled
+ * source has waited longer than OVERDUE_MS. Without it the API still
+ * serves what was fetched, but nothing new arrives
+ *
+ * Gauges: sources by health status, items (current and removed), the queue (due sources, runs in
+ * flight), the time of the last finished fetch and of the last successful one, and the outbox backlog.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+
+const OVERDUE_MS = 15 * 60 * 1000;
+const STATUSES = ['healthy', 'stale', 'failing', 'never_fetched', 'disabled', 'manual'];
+
+function readers(db, { startedAt }) {
+ // A source waits from when it became due, when it was last changed (a new or re-enabled source
+ // is due at once, from next_due_at 0) or when this process started, whichever is latest.
+ const due = db.prepare(`SELECT COUNT(*) AS n, MIN(MAX(next_due_at, not_before, updated_at)) AS oldest FROM sources
+ WHERE enabled = 1 AND type != 'manual' AND next_due_at <= @now AND not_before <= @now`);
+ const lastRun = db.prepare('SELECT finished_at FROM fetch_runs ORDER BY rid DESC LIMIT 1');
+ const lastSuccess = db.prepare('SELECT MAX(last_success_at) AS t FROM sources');
+ const items = db.prepare('SELECT COUNT(*) AS n, COUNT(removed_at) AS removed FROM items');
+ return {
+ /** Enabled fetched sources whose turn has come, and how long the oldest has waited. */
+ queue(t) {
+ const r = due.get({ now: t });
+ return { due: r.n, oldest_wait_ms: r.n ? Math.max(0, t - Math.max(r.oldest, startedAt)) : 0 };
+ },
+ lastFetchAt: () => { const r = lastRun.get(); return r ? r.finished_at : null; },
+ lastSuccessAt: () => lastSuccess.get().t,
+ items: () => { const r = items.get(); return { current: r.n - r.removed, removed: r.removed }; },
+ };
+}
+
+function createSourcesReadiness({ db, keys, config, registry, ingest, scheduler, outbox, relay, now, release = null }) {
+ const read = readers(db, { startedAt: now() });
+ const iso = (v) => (v == null ? null : new Date(v).toISOString());
+ return createReadiness({
+ service: 'sources',
+ release,
+ checks: [
+ { name: 'db', required: true, check: () => { db.prepare('SELECT COUNT(*) AS n FROM sources').get(); return true; } },
+ { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: no service token can be verified' },
+ {
+ name: 'fetcher', required: false,
+ check: () => {
+ const q = read.queue(now());
+ const detail = {
+ enabled: config.worker.enabled, running: scheduler.running(), in_flight: ingest.inflight().length,
+ max_concurrent: config.worker.maxConcurrent, due: q.due, oldest_wait_ms: q.oldest_wait_ms,
+ last_fetch_at: iso(read.lastFetchAt()),
+ };
+ if (!config.worker.enabled) return { ok: false, error: 'the ingestion worker is off (SOURCES_WORKER=off): nothing is fetched', detail };
+ if (!scheduler.running()) return { ok: false, error: 'the ingestion worker is not running', detail };
+ if (q.oldest_wait_ms > OVERDUE_MS) return { ok: false, error: `the fetch queue is behind: ${q.due} source(s) due, the oldest for ${Math.round(q.oldest_wait_ms / 60000)} min`, detail };
+ return { ok: true, detail };
+ },
+ },
+ ],
+ details: (body) => {
+ const dbOk = body.checks.db.status === 'ok';
+ let sources = null;
+ if (dbOk) {
+ sources = {};
+ for (const r of registry.all()) {
+ const s = registry.health(r).status;
+ sources[s] = (sources[s] || 0) + 1;
+ }
+ }
+ return {
+ sources,
+ runs_in_flight: ingest.inflight().length,
+ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
+ };
+ },
+ });
+}
+
+/** Sources gauges on the openvibe-shared/metrics registry. */
+function registerSourcesGauges(registry, { db, sources, ingest, outbox, now }) {
+ const read = readers(db, { startedAt: now() });
+ const seconds = (ms) => (ms == null ? null : ms / 1000);
+ registry.gauge({
+ name: 'sources_sources', help: 'Registered sources by health status', labelNames: ['status'],
+ collect: () => {
+ const n = Object.fromEntries(STATUSES.map((s) => [s, 0]));
+ for (const r of sources.all()) { const s = sources.health(r).status; n[s] = (n[s] || 0) + 1; }
+ return Object.entries(n).map(([status, value]) => ({ labels: { status }, value }));
+ },
+ });
+ registry.gauge({
+ name: 'sources_items', help: 'Items held, current and removed', labelNames: ['state'],
+ collect: () => Object.entries(read.items()).map(([state, value]) => ({ labels: { state }, value })),
+ });
+ registry.gauge({ name: 'sources_fetch_due', help: 'Enabled sources whose next fetch is due now (the fetch queue)', collect: () => read.queue(now()).due });
+ registry.gauge({ name: 'sources_fetch_oldest_wait_seconds', help: 'How long the longest-waiting due source has waited', collect: () => read.queue(now()).oldest_wait_ms / 1000 });
+ registry.gauge({ name: 'sources_fetch_in_flight', help: 'Fetch runs in progress', collect: () => ingest.inflight().length });
+ // Left out until there is a fetch: a timestamp of 0 would read as "1970", not "never".
+ registry.gauge({ name: 'sources_last_fetch_timestamp_seconds', help: 'When the last fetch run finished (any outcome), Unix seconds', collect: () => seconds(read.lastFetchAt()) });
+ registry.gauge({ name: 'sources_last_success_timestamp_seconds', help: 'When a source was last fetched successfully, Unix seconds', collect: () => seconds(read.lastSuccessAt()) });
+ registry.gauge({ name: 'sources_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.pending() });
+}
+
+module.exports = { createSourcesReadiness, registerSourcesGauges, OVERDUE_MS };
@@ -147,7 +147,7 @@ t('health lists what needs attention', async () => {
@@ -147,7 +147,7 @@ t('health lists what needs attention', async () => {
assert.ok(h.body.attention.some(a => a.key === 'never' && a.status === 'never_fetched'));
assert.ok(h.body.attention.some(a => a.key === 'never' && a.status === 'never_fetched'));
const ready = await api('GET', '/api/ready');
const ready = await api('GET', '/api/ready');
assert.strictEqual(ready.status, 200);
assert.strictEqual(ready.status, 200);
- assert.strictEqual(ready.body.checks.db, true);
+ assert.strictEqual(ready.body.checks.db.status, 'ok');
});
});
t('done', async () => { await web.close(); await svc.stop(); });
t('done', async () => { await web.close(); await svc.stop(); });
@@ -40,7 +40,7 @@ function tmpDir() {
@@ -40,7 +40,7 @@ function tmpDir() {
}
}
/** Boot Sources; the scheduler is off unless worker: 'on'. Loopback is allowlisted for the stubs. */
/** Boot Sources; the scheduler is off unless worker: 'on'. Loopback is allowlisted for the stubs. */
-async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient } = {}) {
+async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now } = {}) {
const dir = tmpDir();
const dir = tmpDir();
const config = load({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
@@ -54,7 +54,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient } = {})
@@ -54,7 +54,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient } = {})
SOURCES_TICK_MS: '50',
SOURCES_TICK_MS: '50',
...env,
...env,
});
});
- const h = await start({ config, log: silent, lookupImpl, tokenClient });
+ const h = await start({ config, log: silent, lookupImpl, tokenClient, ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, async stop() { await h.close(); } };
return { ...h, base, dir, async stop() { await h.close(); } };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+// Track O: GET /metrics answers direct loopback callers only, labels requests by route template and
+// carries the Sources gauges (sources by status, items, the fetch queue, last fetch); /api/ready is
+// 503 when the database fails, and a fetcher that is off, stopped or behind degrades it.
+const assert = require('assert');
+const nodeHttp = require('http');
+const { boot, request, serviceToken, site, sourceDef, rss, suite } = require('./helpers');
+const { OVERDUE_MS } = require('../server/observability');
+
+const t = suite('observability');
+const ADMIN = serviceToken('network', ['sources.source.manage']);
+const READER = serviceToken('news', ['sources.source.read', 'sources.item.read']);
+let svc;
+let web;
+
+function get(base, p, headers = {}) {
+ return new Promise((resolve, reject) => nodeHttp.get(base + p, { headers }, (res) => {
+ let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b }));
+ }).on('error', reject));
+}
+
+t('boot (worker off) and fetch one feed of three items by hand', async () => {
+ svc = await boot();
+ await svc.keyLoaded;
+ web = await site({
+ '/robots.txt': () => ({ status: 404 }),
+ '/feed.xml': () => ({ body: rss([1, 2, 3].map(i => ({ guid: `g${i}`, title: `Item ${i}`, link: `https://example.org/${i}` }))) }),
+ });
+ const c = await request(svc.base, 'POST', '/api/v1/sources', { token: ADMIN, body: sourceDef({ key: 'feed-obs', endpoints: [`${web.origin}/feed.xml`] }) });
+ assert.strictEqual(c.status, 201, c.text);
+ const f = await request(svc.base, 'POST', '/api/v1/sources/feed-obs/fetch', { token: ADMIN });
+ assert.deepStrictEqual(f.body.runs.map(r => [r.state, r.items.created]), [['ok', 3]]);
+});
+
+t('/api/ready: db is the only required check; a worker that is off degrades it (still 200)', async () => {
+ const r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.ready, true);
+ assert.strictEqual(r.body.status, 'degraded');
+ assert.strictEqual(r.body.service, 'sources');
+ assert.deepStrictEqual(Object.keys(r.body.checks), ['db', 'network_jwks', 'fetcher']);
+ assert.deepStrictEqual(r.body.degraded, ['fetcher']);
+ for (const [name, c] of Object.entries(r.body.checks)) {
+ assert.strictEqual(c.required, name === 'db', name);
+ assert.strictEqual(typeof c.latency_ms, 'number');
+ assert.ok(Date.parse(c.checked_at));
+ }
+ assert.match(r.body.checks.fetcher.error, /SOURCES_WORKER=off/);
+ assert.ok(Date.parse(r.body.checks.fetcher.detail.last_fetch_at));
+ assert.deepStrictEqual(r.body.sources, { healthy: 1 });
+});
+
+t('/metrics: 404 through a proxy; route templates and Sources gauges direct', async () => {
+ await request(svc.base, 'GET', '/api/v1/sources/feed-obs', { token: READER });
+ for (const hdr of [{ 'X-Forwarded-For': '203.0.113.7' }, { 'X-Real-IP': '203.0.113.7' }, { 'CF-Connecting-IP': '203.0.113.7' }]) {
+ const m = await get(svc.base, '/metrics', hdr);
+ assert.strictEqual(m.status, 404, JSON.stringify(hdr));
+ assert.ok(!m.body.includes('sources_items'));
+ }
+ const m = await get(svc.base, '/metrics');
+ assert.strictEqual(m.status, 200);
+ const text = m.body;
+ assert.ok(/http_requests_total\{method="POST",route="\/api\/v1\/sources\/:key\/fetch",status_class="2xx"\} 1\n/.test(text), 'route template');
+ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/sources\/:key",status_class="2xx"\} 1\n/.test(text));
+ assert.ok(!/route="[^"]*feed-obs/.test(text), 'no source key in any label');
+ assert.ok(/\nprocess_resident_memory_bytes \d+\n/.test(text));
+ assert.ok(/release_info\{service="sources",release="[^"]+"\} 1\n/.test(text));
+ assert.ok(/sources_sources\{status="healthy"\} 1\n/.test(text));
+ assert.ok(/sources_sources\{status="failing"\} 0\n/.test(text));
+ assert.ok(/sources_items\{state="current"\} 3\n/.test(text));
+ assert.ok(/sources_items\{state="removed"\} 0\n/.test(text));
+ assert.ok(/\nsources_fetch_due 0\n/.test(text));
+ assert.ok(/\nsources_fetch_in_flight 0\n/.test(text));
+ const last = text.match(/\nsources_last_fetch_timestamp_seconds ([\d.]+)\n/);
+ assert.ok(last && Math.abs(Number(last[1]) - Date.now() / 1000) < 60, 'last fetch time in Unix seconds');
+ assert.ok(/\nsources_last_success_timestamp_seconds [\d.]+\n/.test(text));
+ assert.ok(/\nsources_outbox_pending \d+\n/.test(text));
+});
+
+t('a broken database makes the service unready (503); /metrics still answers', async () => {
+ svc.db.close();
+ const r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual(r.body.ready, false);
+ assert.ok(r.body.failed.includes('db'), r.text);
+ assert.strictEqual(r.body.sources, null);
+ const m = await get(svc.base, '/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.ok(!/sources_items\{/.test(m.body), 'a gauge that cannot be read is left out, not invented');
+ try { await svc.stop(); } catch { /* the database is already closed */ }
+});
+
+t('worker on: ready; a fetch queue that falls behind, or a stopped worker, degrades it', async () => {
+ const clock = { offset: 0 };
+ // No run slot, so nothing due is ever started; the clock moves forward by hand.
+ svc = await boot({ worker: 'on', env: { SOURCES_MAX_CONCURRENT: '0' }, now: () => Date.now() + clock.offset });
+ let r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.body.status, 'ready', r.text);
+ assert.strictEqual(r.body.checks.fetcher.detail.running, true);
+ await request(svc.base, 'POST', '/api/v1/sources', { token: ADMIN, body: sourceDef({ key: 'feed-late', endpoints: [`${web.origin}/feed.xml`] }) });
+ r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.body.status, 'ready', 'a source that just became due is not late');
+ assert.strictEqual(r.body.checks.fetcher.detail.due, 1);
+
+ clock.offset = OVERDUE_MS + 60_000;
+ r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.degraded, ['fetcher']);
+ assert.match(r.body.checks.fetcher.error, /the fetch queue is behind: 1 source\(s\) due, the oldest for 16 min/);
+ const text = (await get(svc.base, '/metrics')).body;
+ assert.ok(/\nsources_fetch_due 1\n/.test(text));
+ const wait = Number(text.match(/\nsources_fetch_oldest_wait_seconds ([\d.]+)\n/)[1]);
+ assert.ok(wait >= OVERDUE_MS / 1000, String(wait));
+
+ clock.offset = 0;
+ await svc.scheduler.stop();
+ r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.body.degraded, ['fetcher']);
+ assert.strictEqual(r.body.checks.fetcher.error, 'the ingestion worker is not running');
+ await svc.stop();
+ await web.close();
+});
+
+t.run();