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
- | GET/health | { ok, uptime } — always public. |
+ | GET/health | { ok, uptime } — liveness, always public. |
+ | GET/ready | Readiness probe: 200 { ready: true } when the DB is reachable, else 503. |
+ | GET/metrics | Prometheus exposition — request rate/latency, memory, realtime connections. No per-tenant labels; restrict at the proxy if needed. |
| GET/v1 | Capabilities + endpoint map + mode + AI provider. |
| GET/v1/stats | Version, uptime, usage counts, AI provider readiness. |
| GET/sdk.js · /sdk.d.ts · /llms.txt | The browser client, its types, and the AI-readable guide. |
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');