openvibers/openvibe.search Commits
Track O: /metrics for direct loopback callers and a truthful /api/ready (openvibe-shared v1.3.0)
Changes
@@@ -33,9 +33,13 @@ env `/etc/openvibe/search.env`, unit [deploy/systemd/openvibe-search.service](deindex `/var/lib/openvibe-search/search.db`, nginx [deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf)(the public vhost exposes only the GET query API and health; owners and Events call `127.0.0.1:4710`).--`GET /api/health` is liveness. `GET /api/ready` is 200 when the database answers and the Network--signing key has loaded; it also reports document counts by exposure, the outbox backlog and--whether the Events webhook is on.++`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the++database (the documents and both full-text tables) fails; a Network signing key that has not loaded++(anonymous queries still answer) and a full-text index out of step with the documents table degrade++it. It also reports document counts by exposure, the outbox backlog and whether the Events webhook++is on. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signals++by route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outbox++backlog.## Query API@@@ -13,6 +13,8 @@## writes; open them only for an off-host owner.## /internal/events OpenVibe.Events deliveries — host-local only.## /api/health, /api/ready++## /metrics Prometheus text, answered only to direct loopback callers++## (never proxied; Node refuses a request carrying X-Forwarded-For)## ═══════════════════════════════════════════════════════════════limit_req_zone $binary_remote_addr zone=ovsearch_query:10m rate=10r/s;@@@ -51,6 +53,9 @@ server {location = /api/health { proxy_pass http://127.0.0.1:4710; 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:4710; 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:4710/metrics).++ location = /metrics { return 404; }++# ── Public query API (GET only) ─────────────────────────location ~ ^/api/v1/(search|suggest|documents/) {limit_except GET { deny all; }@@@ -14,7 +14,8 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "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"@@@ -171,6 +172,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",@@@ -338,6 +345,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",@@@ -699,6 +715,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",@@@ -877,6 +984,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",@@@ -20,7 +20,8 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "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, the Events webhook, health/readiness. */++/** Express app: request context, the v1 API, the Events webhook, 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 { createSearchReadiness, registerSearchGauges } = require('./observability');const { documentsRouter } = require('./api/documents');const { queryRouter } = require('./api/query');const { webhookRouter } = require('./api/webhook');@@@ -12,6 +16,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =app.disable('x-powered-by');app.set('trust proxy', 'loopback');app.set('query parser', 'extended');++ const release = createRelease({ service: 'search', root: path.join(__dirname, '..') });++ // HTTP golden signals by route template, process metrics, release_info and the Search gauges;++ // GET /metrics answers direct loopback callers only (Track O).++ const metrics = instrument(app, { service: 'search', release: release.release });++ registerSearchGauges(metrics.registry, { db, outbox });app.use(http.middleware());app.use((req, res, next) => {res.setHeader('X-Content-Type-Options', 'nosniff');@@@ -27,20 +36,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =res.json({ status: 'ok', service: 'openvibe-search', 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() };-- const ready = Object.values(checks).every(Boolean);-- res.status(ready ? 200 : 503).json({-- status: ready ? 'ready' : 'not_ready',-- checks,-- engine: engine.name,-- documents: dbOk ? store.counts() : null,-- outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,-- webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',-- });-- });++ // Readiness (openvibe-shared/ready): 503 only when the database (documents and full-text tables)++ // fails; the Network key and index consistency are optional and degrade it (see observability.js).++ const readiness = createSearchReadiness({ db, keys, config, engine, store, outbox, relay, release: release.release });++ app.get('/api/ready', readiness.handler);++ app.get('/release.json', release.handler);app.use(documentsRouter({ store, auth, db, relay }));app.use(queryRouter({ config, store, engine, auth }));@@@ -56,7 +56,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log ='DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone (owner)','GET /api/v1/owners/:owner/documents reconciliation (owner)','POST /internal/events OpenVibe.Events delivery (signed)',-- 'GET /api/health, /api/ready',++ 'GET /api/health, /api/ready, /release.json','','Source: https://github.com/OpenVibers/OpenVibe.Search','',@@@ -74,6 +74,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =return http.sendProblem(res, 500, 'search.internal', { detail: 'internal error', ctx: req.ov });});++ app.locals.metrics = metrics;return app;}nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Track O: truthful readiness for GET /api/ready and the Search gauges on GET /metrics++ * (openvibe-shared/ready and openvibe-shared/metrics).++ *++ * db required a real read of the documents table and of both full-text tables (they++ * live in the same SQLite file): without them nothing can be indexed or found++ * network_jwks optional the Network signing key has loaded. Without it anonymous queries still++ * answer (public documents only), but no token can be verified: owner++ * writes and signed-in queries fail, so it degrades rather than fails++ * index optional the full-text index agrees with the documents table: fts_public holds++ * exactly the public_listed documents and fts_restricted the restricted++ * ones. A mismatch means queries miss (or would surface) documents++ *++ * Gauges: documents by exposure, full-text entries by table, and the events outbox backlog.++ */++const { createReadiness } = require('openvibe-shared/ready');++const { EXPOSURE } = require('./document');++++const EXPOSURE_NAMES = Object.fromEntries(Object.entries(EXPOSURE).map(([name, n]) => [n, name]));++++function readers(db) {++ const byExposure = db.prepare('SELECT exposure, COUNT(*) AS n FROM documents GROUP BY exposure');++ const ftsPublic = db.prepare('SELECT COUNT(*) AS n FROM fts_public');++ const ftsRestricted = db.prepare('SELECT COUNT(*) AS n FROM fts_restricted');++ return {++ /** { none, restricted, public_unlisted, public_listed }, every exposure present (0 when empty). */++ documents() {++ const out = Object.fromEntries(Object.keys(EXPOSURE).map((k) => [k, 0]));++ for (const r of byExposure.all()) out[EXPOSURE_NAMES[r.exposure] || 'none'] += r.n;++ return out;++ },++ indexed: () => ({ public: ftsPublic.get().n, restricted: ftsRestricted.get().n }),++ };++}++++function createSearchReadiness({ db, keys, config, engine, store, outbox, relay, release = null }) {++ const read = readers(db);++ return createReadiness({++ service: 'search',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: () => {++ db.prepare('SELECT rid FROM documents LIMIT 1').all();++ db.prepare('SELECT rowid FROM fts_public LIMIT 1').all();++ db.prepare('SELECT rowid FROM fts_restricted LIMIT 1').all();++ return true;++ },++ },++ { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: owner writes and signed-in queries cannot be verified' },++ {++ name: 'index', required: false, cacheMs: 10_000,++ check: () => {++ const docs = read.documents();++ const fts = read.indexed();++ const detail = { indexed: fts, expected: { public: docs.public_listed, restricted: docs.restricted } };++ const off = [];++ if (fts.public !== docs.public_listed) off.push(`fts_public has ${fts.public} entries for ${docs.public_listed} public_listed documents`);++ if (fts.restricted !== docs.restricted) off.push(`fts_restricted has ${fts.restricted} entries for ${docs.restricted} restricted documents`);++ return off.length ? { ok: false, error: `index out of step: ${off.join('; ')}`, detail } : { ok: true, detail };++ },++ },++ ],++ details: (body) => {++ const dbOk = body.checks.db.status === 'ok';++ return {++ engine: engine.name,++ documents: dbOk ? store.counts() : null,++ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,++ webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',++ };++ },++ });++}++++/** Search gauges on the openvibe-shared/metrics registry. */++function registerSearchGauges(registry, { db, outbox }) {++ const read = readers(db);++ registry.gauge({++ name: 'search_documents', help: 'Documents held, by exposure (public_listed and restricted are searchable; tombstones and drafts are none)', labelNames: ['exposure'],++ collect: () => Object.entries(read.documents()).map(([exposure, value]) => ({ labels: { exposure }, value })),++ });++ registry.gauge({++ name: 'search_documents_indexed', help: 'Documents in the full-text index, by table', labelNames: ['index'],++ collect: () => Object.entries(read.indexed()).map(([index, value]) => ({ labels: { index }, value })),++ });++ registry.gauge({ name: 'search_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.pending() });++ registry.gauge({ name: 'search_outbox_rejected', help: 'Events OpenVibe.Events refused for good', collect: () => outbox.rejected() });++}++++module.exports = { createSearchReadiness, registerSearchGauges };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// Track O: GET /metrics answers direct loopback callers only, labels requests by route template and++// carries the Search gauges (documents by exposure, full-text entries); /api/ready is 503 when the++// database fails, and a full-text index out of step with the documents degrades it.++const assert = require('assert');++const nodeHttp = require('http');++const { boot, request, serviceToken, doc, suite } = require('./helpers');++++const t = suite('observability');++const WIKI = serviceToken('wiki', ['search.document.write']);++let svc;++++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));++}++const put = (d) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token: WIKI, body: d });++++t('boot and index two public documents, one members-only and one noindex', async () => {++ svc = await boot();++ await svc.keyLoaded;++ for (const d of [doc(), doc(), doc({ visibility: 'members', acl: { groups: ['vip'] } }), doc({ indexability: { decision: 'noindex', reasons: ['thin'] } })]) {++ const r = await put(d);++ assert.ok(r.status === 200 || r.status === 201, r.text);++ }++});++++t('/api/ready: every check reports; db is the only required one; the index agrees', async () => {++ const r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.status, 'ready');++ assert.strictEqual(r.body.service, 'search');++ assert.deepStrictEqual(Object.keys(r.body.checks), ['db', 'network_jwks', 'index']);++ for (const [name, c] of Object.entries(r.body.checks)) {++ assert.strictEqual(c.status, 'ok', `${name}: ${c.error}`);++ assert.strictEqual(c.required, name === 'db', name);++ assert.strictEqual(typeof c.latency_ms, 'number');++ assert.ok(Date.parse(c.checked_at));++ }++ assert.deepStrictEqual(r.body.checks.index.detail, { indexed: { public: 2, restricted: 1 }, expected: { public: 2, restricted: 1 } });++ assert.strictEqual(r.body.engine, 'sqlite-fts5');++ assert.strictEqual(r.body.documents.total, 4);++});++++t('/metrics: 404 through a proxy; route templates and Search gauges direct', async () => {++ const d = doc({ id: 'pg_metricsdoc0001' });++ await put(d);++ await request(svc.base, 'GET', `/api/v1/documents/wiki/page/${d.id}`);++ await request(svc.base, 'GET', '/api/v1/search?q=example');++ 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('search_documents'));++ }++ const m = await get(svc.base, '/metrics');++ assert.strictEqual(m.status, 200);++ const text = m.body;++ assert.ok(/http_requests_total\{method="PUT",route="\/api\/v1\/documents\/:owner\/:type\/:id",status_class="2xx"\} 5\n/.test(text), 'route template for writes');++ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/documents\/:owner\/:type\/:id",status_class="2xx"\} 1\n/.test(text));++ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/search",status_class="2xx"\} 1\n/.test(text));++ assert.ok(!/route="[^"]*(pg_metricsdoc0001|example)/.test(text), 'no id or query in any label');++ assert.ok(/\nprocess_resident_memory_bytes \d+\n/.test(text));++ assert.ok(/release_info\{service="search",release="[^"]+"\} 1\n/.test(text));++ assert.ok(/search_documents\{exposure="public_listed"\} 3\n/.test(text));++ assert.ok(/search_documents\{exposure="public_unlisted"\} 1\n/.test(text));++ assert.ok(/search_documents\{exposure="restricted"\} 1\n/.test(text));++ assert.ok(/search_documents\{exposure="none"\} 0\n/.test(text));++ assert.ok(/search_documents_indexed\{index="public"\} 3\n/.test(text));++ assert.ok(/search_documents_indexed\{index="restricted"\} 1\n/.test(text));++ assert.ok(/\nsearch_outbox_pending \d+\n/.test(text));++});++++t('a full-text index out of step with the documents degrades /api/ready (still 200)', async () => {++ await svc.stop();++ svc = await boot(); // fresh: the index check has no cached result++ await put(doc());++ await put(doc());++ svc.db.prepare('DELETE FROM fts_public WHERE rowid = (SELECT MIN(rowid) FROM fts_public)').run();++ const r = await request(svc.base, 'GET', '/api/ready');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.status, 'degraded');++ assert.deepStrictEqual(r.body.degraded, ['index']);++ assert.match(r.body.checks.index.error, /fts_public has 1 entries for 2 public_listed documents/);++});++++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.deepStrictEqual(r.body.failed, ['db']);++ assert.strictEqual(r.body.documents, null);++ const m = await get(svc.base, '/metrics');++ assert.strictEqual(m.status, 200);++ assert.ok(!/search_documents\{/.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.run();
@@ -33,9 +33,13 @@ env `/etc/openvibe/search.env`, unit [deploy/systemd/openvibe-search.service](de
@@ -33,9 +33,13 @@ env `/etc/openvibe/search.env`, unit [deploy/systemd/openvibe-search.service](de
index `/var/lib/openvibe-search/search.db`, nginx [deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf)
index `/var/lib/openvibe-search/search.db`, nginx [deploy/nginx/search.openvibe.network.conf](deploy/nginx/search.openvibe.network.conf)
(the public vhost exposes only the GET query API and health; owners and Events call `127.0.0.1:4710`).
(the public vhost exposes only the GET query API and health; owners and Events call `127.0.0.1:4710`).
-`GET /api/health` is liveness. `GET /api/ready` is 200 when the database answers and the Network
-signing key has loaded; it also reports document counts by exposure, the outbox backlog and
-whether the Events webhook is on.
+`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 503 only when the
+database (the documents and both full-text tables) fails; a Network signing key that has not loaded
+(anonymous queries still answer) and a full-text index out of step with the documents table degrade
+it. It also reports document counts by exposure, the outbox backlog and whether the Events webhook
+is on. `GET /metrics` (openvibe-shared/metrics) answers direct loopback callers only: golden signals
+by route template, `search_documents{exposure}`, `search_documents_indexed{index}` and the outbox
+backlog.
## Query API
## Query API
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
## writes; open them only for an off-host owner.
## writes; open them only for an off-host owner.
## /internal/events OpenVibe.Events deliveries — host-local only.
## /internal/events OpenVibe.Events deliveries — host-local only.
## /api/health, /api/ready
## /api/health, /api/ready
+## /metrics Prometheus text, answered only to direct loopback callers
+## (never proxied; Node refuses a request carrying X-Forwarded-For)
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=ovsearch_query:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovsearch_query:10m rate=10r/s;
@@ -51,6 +53,9 @@ server {
@@ -51,6 +53,9 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4710; 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:4710; 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:4710; 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:4710; 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:4710/metrics).
+ location = /metrics { return 404; }
+
# ── Public query API (GET only) ─────────────────────────
# ── Public query API (GET only) ─────────────────────────
location ~ ^/api/v1/(search|suggest|documents/) {
location ~ ^/api/v1/(search|suggest|documents/) {
limit_except GET { deny all; }
limit_except GET { deny all; }
@@ -14,7 +14,8 @@
@@ -14,7 +14,8 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "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"
@@ -171,6 +172,12 @@
@@ -171,6 +172,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",
@@ -338,6 +345,15 @@
@@ -338,6 +345,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",
@@ -699,6 +715,97 @@
@@ -699,6 +715,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",
@@ -877,6 +984,15 @@
@@ -877,6 +984,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",
@@ -20,7 +20,8 @@
@@ -20,7 +20,8 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "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, the Events webhook, health/readiness. */
+/** Express app: request context, the v1 API, the Events webhook, 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 { createSearchReadiness, registerSearchGauges } = require('./observability');
const { documentsRouter } = require('./api/documents');
const { documentsRouter } = require('./api/documents');
const { queryRouter } = require('./api/query');
const { queryRouter } = require('./api/query');
const { webhookRouter } = require('./api/webhook');
const { webhookRouter } = require('./api/webhook');
@@ -12,6 +16,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
@@ -12,6 +16,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
app.set('query parser', 'extended');
app.set('query parser', 'extended');
+ const release = createRelease({ service: 'search', root: path.join(__dirname, '..') });
+ // HTTP golden signals by route template, process metrics, release_info and the Search gauges;
+ // GET /metrics answers direct loopback callers only (Track O).
+ const metrics = instrument(app, { service: 'search', release: release.release });
+ registerSearchGauges(metrics.registry, { db, outbox });
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');
@@ -27,20 +36,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
@@ -27,20 +36,11 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
res.json({ status: 'ok', service: 'openvibe-search', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-search', 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() };
- const ready = Object.values(checks).every(Boolean);
- res.status(ready ? 200 : 503).json({
- status: ready ? 'ready' : 'not_ready',
- checks,
- engine: engine.name,
- documents: dbOk ? store.counts() : null,
- outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
- webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',
- });
- });
+ // Readiness (openvibe-shared/ready): 503 only when the database (documents and full-text tables)
+ // fails; the Network key and index consistency are optional and degrade it (see observability.js).
+ const readiness = createSearchReadiness({ db, keys, config, engine, store, outbox, relay, release: release.release });
+ app.get('/api/ready', readiness.handler);
+ app.get('/release.json', release.handler);
app.use(documentsRouter({ store, auth, db, relay }));
app.use(documentsRouter({ store, auth, db, relay }));
app.use(queryRouter({ config, store, engine, auth }));
app.use(queryRouter({ config, store, engine, auth }));
@@ -56,7 +56,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
@@ -56,7 +56,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
'DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone (owner)',
'DELETE /api/v1/documents/:owner/:type/:id?revision= tombstone (owner)',
'GET /api/v1/owners/:owner/documents reconciliation (owner)',
'GET /api/v1/owners/:owner/documents reconciliation (owner)',
'POST /internal/events OpenVibe.Events delivery (signed)',
'POST /internal/events OpenVibe.Events delivery (signed)',
- 'GET /api/health, /api/ready',
+ 'GET /api/health, /api/ready, /release.json',
'',
'',
'Source: https://github.com/OpenVibers/OpenVibe.Search',
'Source: https://github.com/OpenVibers/OpenVibe.Search',
'',
'',
@@ -74,6 +74,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
@@ -74,6 +74,7 @@ function createApp({ config, db, store, engine, auth, keys, outbox, relay, log =
return http.sendProblem(res, 500, 'search.internal', { detail: 'internal error', ctx: req.ov });
return http.sendProblem(res, 500, 'search.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,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Track O: truthful readiness for GET /api/ready and the Search gauges on GET /metrics
+ * (openvibe-shared/ready and openvibe-shared/metrics).
+ *
+ * db required a real read of the documents table and of both full-text tables (they
+ * live in the same SQLite file): without them nothing can be indexed or found
+ * network_jwks optional the Network signing key has loaded. Without it anonymous queries still
+ * answer (public documents only), but no token can be verified: owner
+ * writes and signed-in queries fail, so it degrades rather than fails
+ * index optional the full-text index agrees with the documents table: fts_public holds
+ * exactly the public_listed documents and fts_restricted the restricted
+ * ones. A mismatch means queries miss (or would surface) documents
+ *
+ * Gauges: documents by exposure, full-text entries by table, and the events outbox backlog.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+const { EXPOSURE } = require('./document');
+
+const EXPOSURE_NAMES = Object.fromEntries(Object.entries(EXPOSURE).map(([name, n]) => [n, name]));
+
+function readers(db) {
+ const byExposure = db.prepare('SELECT exposure, COUNT(*) AS n FROM documents GROUP BY exposure');
+ const ftsPublic = db.prepare('SELECT COUNT(*) AS n FROM fts_public');
+ const ftsRestricted = db.prepare('SELECT COUNT(*) AS n FROM fts_restricted');
+ return {
+ /** { none, restricted, public_unlisted, public_listed }, every exposure present (0 when empty). */
+ documents() {
+ const out = Object.fromEntries(Object.keys(EXPOSURE).map((k) => [k, 0]));
+ for (const r of byExposure.all()) out[EXPOSURE_NAMES[r.exposure] || 'none'] += r.n;
+ return out;
+ },
+ indexed: () => ({ public: ftsPublic.get().n, restricted: ftsRestricted.get().n }),
+ };
+}
+
+function createSearchReadiness({ db, keys, config, engine, store, outbox, relay, release = null }) {
+ const read = readers(db);
+ return createReadiness({
+ service: 'search',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: () => {
+ db.prepare('SELECT rid FROM documents LIMIT 1').all();
+ db.prepare('SELECT rowid FROM fts_public LIMIT 1').all();
+ db.prepare('SELECT rowid FROM fts_restricted LIMIT 1').all();
+ return true;
+ },
+ },
+ { name: 'network_jwks', required: false, check: () => keys.loaded() || 'Network signing key not loaded yet: owner writes and signed-in queries cannot be verified' },
+ {
+ name: 'index', required: false, cacheMs: 10_000,
+ check: () => {
+ const docs = read.documents();
+ const fts = read.indexed();
+ const detail = { indexed: fts, expected: { public: docs.public_listed, restricted: docs.restricted } };
+ const off = [];
+ if (fts.public !== docs.public_listed) off.push(`fts_public has ${fts.public} entries for ${docs.public_listed} public_listed documents`);
+ if (fts.restricted !== docs.restricted) off.push(`fts_restricted has ${fts.restricted} entries for ${docs.restricted} restricted documents`);
+ return off.length ? { ok: false, error: `index out of step: ${off.join('; ')}`, detail } : { ok: true, detail };
+ },
+ },
+ ],
+ details: (body) => {
+ const dbOk = body.checks.db.status === 'ok';
+ return {
+ engine: engine.name,
+ documents: dbOk ? store.counts() : null,
+ outbox: dbOk ? { pending: outbox.pending(), rejected: outbox.rejected(), relay: config.events.url ? (relay.running() ? 'running' : 'stopped') : 'off (EVENTS_URL unset)' } : null,
+ webhook: config.events.webhookSecrets.length ? 'on' : 'off (SEARCH_EVENTS_SECRET unset)',
+ };
+ },
+ });
+}
+
+/** Search gauges on the openvibe-shared/metrics registry. */
+function registerSearchGauges(registry, { db, outbox }) {
+ const read = readers(db);
+ registry.gauge({
+ name: 'search_documents', help: 'Documents held, by exposure (public_listed and restricted are searchable; tombstones and drafts are none)', labelNames: ['exposure'],
+ collect: () => Object.entries(read.documents()).map(([exposure, value]) => ({ labels: { exposure }, value })),
+ });
+ registry.gauge({
+ name: 'search_documents_indexed', help: 'Documents in the full-text index, by table', labelNames: ['index'],
+ collect: () => Object.entries(read.indexed()).map(([index, value]) => ({ labels: { index }, value })),
+ });
+ registry.gauge({ name: 'search_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.pending() });
+ registry.gauge({ name: 'search_outbox_rejected', help: 'Events OpenVibe.Events refused for good', collect: () => outbox.rejected() });
+}
+
+module.exports = { createSearchReadiness, registerSearchGauges };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// Track O: GET /metrics answers direct loopback callers only, labels requests by route template and
+// carries the Search gauges (documents by exposure, full-text entries); /api/ready is 503 when the
+// database fails, and a full-text index out of step with the documents degrades it.
+const assert = require('assert');
+const nodeHttp = require('http');
+const { boot, request, serviceToken, doc, suite } = require('./helpers');
+
+const t = suite('observability');
+const WIKI = serviceToken('wiki', ['search.document.write']);
+let svc;
+
+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));
+}
+const put = (d) => request(svc.base, 'PUT', `/api/v1/documents/${d.owner}/${d.type}/${d.id}`, { token: WIKI, body: d });
+
+t('boot and index two public documents, one members-only and one noindex', async () => {
+ svc = await boot();
+ await svc.keyLoaded;
+ for (const d of [doc(), doc(), doc({ visibility: 'members', acl: { groups: ['vip'] } }), doc({ indexability: { decision: 'noindex', reasons: ['thin'] } })]) {
+ const r = await put(d);
+ assert.ok(r.status === 200 || r.status === 201, r.text);
+ }
+});
+
+t('/api/ready: every check reports; db is the only required one; the index agrees', async () => {
+ const r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.status, 'ready');
+ assert.strictEqual(r.body.service, 'search');
+ assert.deepStrictEqual(Object.keys(r.body.checks), ['db', 'network_jwks', 'index']);
+ for (const [name, c] of Object.entries(r.body.checks)) {
+ assert.strictEqual(c.status, 'ok', `${name}: ${c.error}`);
+ assert.strictEqual(c.required, name === 'db', name);
+ assert.strictEqual(typeof c.latency_ms, 'number');
+ assert.ok(Date.parse(c.checked_at));
+ }
+ assert.deepStrictEqual(r.body.checks.index.detail, { indexed: { public: 2, restricted: 1 }, expected: { public: 2, restricted: 1 } });
+ assert.strictEqual(r.body.engine, 'sqlite-fts5');
+ assert.strictEqual(r.body.documents.total, 4);
+});
+
+t('/metrics: 404 through a proxy; route templates and Search gauges direct', async () => {
+ const d = doc({ id: 'pg_metricsdoc0001' });
+ await put(d);
+ await request(svc.base, 'GET', `/api/v1/documents/wiki/page/${d.id}`);
+ await request(svc.base, 'GET', '/api/v1/search?q=example');
+ 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('search_documents'));
+ }
+ const m = await get(svc.base, '/metrics');
+ assert.strictEqual(m.status, 200);
+ const text = m.body;
+ assert.ok(/http_requests_total\{method="PUT",route="\/api\/v1\/documents\/:owner\/:type\/:id",status_class="2xx"\} 5\n/.test(text), 'route template for writes');
+ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/documents\/:owner\/:type\/:id",status_class="2xx"\} 1\n/.test(text));
+ assert.ok(/http_requests_total\{method="GET",route="\/api\/v1\/search",status_class="2xx"\} 1\n/.test(text));
+ assert.ok(!/route="[^"]*(pg_metricsdoc0001|example)/.test(text), 'no id or query in any label');
+ assert.ok(/\nprocess_resident_memory_bytes \d+\n/.test(text));
+ assert.ok(/release_info\{service="search",release="[^"]+"\} 1\n/.test(text));
+ assert.ok(/search_documents\{exposure="public_listed"\} 3\n/.test(text));
+ assert.ok(/search_documents\{exposure="public_unlisted"\} 1\n/.test(text));
+ assert.ok(/search_documents\{exposure="restricted"\} 1\n/.test(text));
+ assert.ok(/search_documents\{exposure="none"\} 0\n/.test(text));
+ assert.ok(/search_documents_indexed\{index="public"\} 3\n/.test(text));
+ assert.ok(/search_documents_indexed\{index="restricted"\} 1\n/.test(text));
+ assert.ok(/\nsearch_outbox_pending \d+\n/.test(text));
+});
+
+t('a full-text index out of step with the documents degrades /api/ready (still 200)', async () => {
+ await svc.stop();
+ svc = await boot(); // fresh: the index check has no cached result
+ await put(doc());
+ await put(doc());
+ svc.db.prepare('DELETE FROM fts_public WHERE rowid = (SELECT MIN(rowid) FROM fts_public)').run();
+ const r = await request(svc.base, 'GET', '/api/ready');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.status, 'degraded');
+ assert.deepStrictEqual(r.body.degraded, ['index']);
+ assert.match(r.body.checks.index.error, /fts_public has 1 entries for 2 public_listed documents/);
+});
+
+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.deepStrictEqual(r.body.failed, ['db']);
+ assert.strictEqual(r.body.documents, null);
+ const m = await get(svc.base, '/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.ok(!/search_documents\{/.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.run();