From 576a6e44b306c32f2ef49e9954516e5bfddce912 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 09:34:41 +0000 Subject: [PATCH] Observability: Prometheus /metrics + a /ready probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add operational endpoints for running the server under an orchestrator / scrape, both public like /health and carrying no per-tenant labels. - GET /ready — readiness probe: 200 { ready: true } when the DB answers SELECT 1, else 503. Pairs with /health (liveness) for k8s-style probes. - GET /metrics — Prometheus exposition via prom-client on a per-instance registry (so multiple servers in one process don't collide): request rate + latency histogram labeled by method/route *pattern* (ids never become labels), process memory/uptime, and realtime connection/channel gauges. /metrics, /health, and /ready are excluded from the request metrics to avoid scrape/probe noise. Verification: 66/66 SQLite (+ a test asserting /ready and the metric names + route-pattern labels), 48/48 Postgres 16. Docs updated (README, /docs meta). --- README.md | 3 +- package-lock.json | 40 +++++++++++++++++++++++++- package.json | 3 +- public/docs.html | 4 ++- src/metrics.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 4 +++ test/api.test.ts | 18 ++++++++++++ 7 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/metrics.ts diff --git a/README.md b/README.md index de434b4..cc0d9e6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ That's it — no clone, no config, no database to provision. Open Plus a **zero-build browser SDK** (`/sdk.js`, typed), a **self-describing API** (`/v1` + `/llms.txt`) an AI can consume in one read, an **admin dashboard** -(`/admin`), an ops endpoint (`/v1/stats`), per-IP rate limiting, and one-command +(`/admin`), ops endpoints (`/v1/stats`, Prometheus `/metrics`, a `/ready` probe), +per-IP rate limiting, and one-command deploys (npx · Docker Compose · systemd). TypeScript + Fastify, single process, SQLite + local disk by default — Postgres and S3 optional. CI, MIT. diff --git a/package-lock.json b/package-lock.json index b231790..4d9741e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "ioredis": "^5.11.1", - "pg": "^8.21.0" + "pg": "^8.21.0", + "prom-client": "^15.1.3" }, "bin": { "zero-config-data-api": "bin/zero-config-data-api.mjs" @@ -1347,6 +1348,15 @@ ], "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1653,6 +1663,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2648,6 +2664,19 @@ ], "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -3013,6 +3042,15 @@ "node": ">=6" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", diff --git a/package.json b/package.json index 68902ec..ab13f4f 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "ioredis": "^5.11.1", - "pg": "^8.21.0" + "pg": "^8.21.0", + "prom-client": "^15.1.3" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/public/docs.html b/public/docs.html index e8fbfd5..8859418 100644 --- a/public/docs.html +++ b/public/docs.html @@ -300,7 +300,9 @@

REST API — Admin & backup

Meta & health

- + + + diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..9af4ca1 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,72 @@ +import { Registry, Counter, Histogram, Gauge } from 'prom-client'; +import type { FastifyInstance } from 'fastify'; + +// Paths that are themselves infra (probes/scrape) — excluded from request metrics +// so they don't drown out real traffic. +const SKIP = new Set(['/metrics', '/health', '/ready']); + +/** + * Operational endpoints for running the server under an orchestrator / scrape: + * GET /metrics — Prometheus exposition (request rate, latency, memory, realtime) + * GET /ready — readiness probe; 200 when the database is reachable, else 503 + * + * Both are public (like /health) and carry no per-tenant labels, so nothing + * sensitive leaks — restrict at the proxy/network if you want them private. + */ +export function registerObservability(app: FastifyInstance): void { + // A per-instance registry (not the global default) so multiple servers in one + // process — e.g. the test suite — don't collide on metric registration. + const registry = new Registry(); + + const gauge = (name: string, help: string, collect: () => number): void => { + new Gauge({ name, help, registers: [registry], collect() { + this.set(collect()); + } }); + }; + gauge('zero_uptime_seconds', 'Process uptime in seconds.', () => process.uptime()); + gauge('zero_resident_memory_bytes', 'Resident set size in bytes.', () => process.memoryUsage().rss); + gauge('zero_heap_used_bytes', 'V8 heap used in bytes.', () => process.memoryUsage().heapUsed); + gauge('zero_realtime_connections', 'Open realtime websocket connections (this process).', () => + app.realtime.channels().reduce((n, c) => n + c.clients, 0), + ); + gauge('zero_realtime_channels', 'Active realtime channels (this process).', () => app.realtime.channels().length); + + const requests = new Counter({ + name: 'zero_http_requests_total', + help: 'HTTP requests handled, by method, route, and status.', + labelNames: ['method', 'route', 'status'] as const, + registers: [registry], + }); + const duration = new Histogram({ + name: 'zero_http_request_duration_seconds', + help: 'HTTP request duration in seconds, by method and route.', + labelNames: ['method', 'route'] as const, + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [registry], + }); + + app.addHook('onResponse', async (req, reply) => { + if (SKIP.has(req.url.split('?')[0] ?? '')) return; + // routeOptions.url is the route *pattern* (e.g. /v1/data/:collection/:id), so + // labels stay low-cardinality — ids never become distinct label values. + const route = req.routeOptions?.url ?? 'unmatched'; + const method = req.method; + requests.inc({ method, route, status: reply.statusCode }); + duration.observe({ method, route }, reply.elapsedTime / 1000); + }); + + app.get('/metrics', async (_req, reply) => { + reply.header('content-type', registry.contentType); + return registry.metrics(); + }); + + app.get('/ready', async (_req, reply) => { + try { + await app.db.get('SELECT 1 AS ok'); + return { ready: true }; + } catch (err) { + app.log.error({ err }, 'readiness check failed'); + return reply.code(503).send({ ready: false, error: 'database_unreachable' }); + } + }); +} diff --git a/src/server.ts b/src/server.ts index 38a0255..a7696d7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { getRealtimeTransport } from './modules/realtime/transport.js'; import { metaRoutes } from './modules/meta/routes.js'; import { backupRoutes } from './modules/backup/routes.js'; import { registerRateLimit, type RateLimitOptions } from './rate-limit.js'; +import { registerObservability } from './metrics.js'; import { AclStore } from './modules/acl/store.js'; import { SchemaStore, SchemaValidationError } from './modules/data/schema.js'; import { getBlobStore } from './modules/files/blob.js'; @@ -255,6 +256,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise ({ ok: true, uptime: process.uptime() })); + // /metrics (Prometheus) + /ready (readiness probe) for scaled deployments. + registerObservability(app); + await app.register(metaRoutes); await app.register(adminRoutes); await app.register(backupRoutes); diff --git a/test/api.test.ts b/test/api.test.ts index d6812e1..edb4970 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -32,6 +32,24 @@ test('health check', async () => { assert.equal((await json(res)).ok, true); }); +test('observability: /ready probes the DB and /metrics exposes Prometheus metrics', async () => { + const ready = await fetch(`${base}/ready`); + assert.equal(ready.status, 200); + assert.equal((await json(ready)).ready, true); + + // Make a request that should be counted, then scrape. + await fetch(`${base}/v1`); + const res = await fetch(`${base}/metrics`); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type') ?? '', /text\/plain/); + const body = await res.text(); + assert.match(body, /zero_uptime_seconds/); + assert.match(body, /zero_http_requests_total/); + assert.match(body, /zero_http_request_duration_seconds/); + // The earlier GET /v1 was recorded with its route pattern (not a raw path). + assert.match(body, /zero_http_requests_total\{[^}]*route="\/v1"[^}]*\}/); +}); + test('observability: responses carry x-request-id, honoring an inbound one', async () => { const echoed = await fetch(`${base}/health`, { headers: { 'x-request-id': 'trace-abc-123' } }); assert.equal(echoed.headers.get('x-request-id'), 'trace-abc-123');
GET/health{ ok, uptime } — always public.
GET/health{ ok, uptime } — liveness, always public.
GET/readyReadiness probe: 200 { ready: true } when the DB is reachable, else 503.
GET/metricsPrometheus exposition — request rate/latency, memory, realtime connections. No per-tenant labels; restrict at the proxy if needed.
GET/v1Capabilities + endpoint map + mode + AI provider.
GET/v1/statsVersion, uptime, usage counts, AI provider readiness.
GET/sdk.js · /sdk.d.ts · /llms.txtThe browser client, its types, and the AI-readable guide.