diff --git a/README.md b/README.md index 50b082c..61db3c7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ npm run index # start indexer (separate terminal) | GET | `/api/v1/tokens/:address` | Token detail | | GET | `/api/v1/tokens/:address/transfers` | Token transfer history | | GET | `/health` | Health check | +| GET | `/metrics` | Prometheus scrape endpoint (see [Metrics](#metrics)) | + +## Metrics + +Both services expose a Prometheus-format scrape endpoint backed by a shared `prom-client` `Registry`, including default Node.js process metrics (CPU, memory, event loop lag, GC): + +- **API service** (`:3000`) — `GET /metrics`, registry defined in [`src/metrics.ts`](./src/metrics.ts). Covers HTTP latency/throughput (`http_request_duration_seconds`, `http_requests_total`), error rates, indexer ingestion health, DB/cache/replica status. Gated by [`metricsAuthGuard`](./src/middleware/metricsAuthGuard.ts): if `METRICS_TOKEN` is set, requests must supply it via `Authorization: Bearer ` or `X-Metrics-Token`, otherwise they get `401`; if unset, only loopback callers (`127.0.0.1`/`::1`) are allowed and everyone else gets `403`. Set `METRICS_TOKEN` when scraping from outside the host (e.g. a remote Prometheus). +- **Indexer service** (`:3001`) — `GET /metrics`, registry defined in [`indexer/src/metrics.js`](./indexer/src/metrics.js). Covers event ingestion, decode latency, RPC errors, and DB pool utilisation. This endpoint is unauthenticated and intended to be scraped only from inside the trusted service network (e.g. the Docker Compose network); do not expose port `3001` publicly without adding equivalent access control. ## Registering a Contract ABI @@ -144,6 +152,7 @@ Because the server caches keys, a typical rotation involves: | `INDEXER_POLL_INTERVAL_MS` | `5000` | Polling interval | | `INDEXER_BATCH_SIZE` | `100` | Ledgers per batch | | `ADMIN_SECRET` | — | Bearer token required by every `/api/admin/*` route (indexer). See [`docs/ADMIN_AUTH.md`](./docs/ADMIN_AUTH.md). | +| `METRICS_TOKEN` | — | Bearer/`X-Metrics-Token` required to scrape the API service's `GET /metrics` from outside the host. Unset restricts it to loopback callers. See [Metrics](#metrics). | | `MOCK_DATA` | `false` | Gates experimental endpoints that fabricate demo data instead of a real integration. See [Experimental & Mock Data](#experimental--mock-data). `ENABLE_EXPERIMENTAL` is accepted as an alias. | ## Experimental & Mock Data diff --git a/src/index.ts b/src/index.ts index 61cb335..c873892 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { apiKeyAuth } from './middleware/apiKeyAuth'; import { auditLogMiddleware } from './middleware/auditLog'; import { asyncHandler } from './middleware/asyncHandler'; import { rejectUntrustedForwardedHeaders } from './middleware/proxyTrust'; +import { metricsAuthGuard } from './middleware/metricsAuthGuard'; import { billingRouter } from './services/stripe-billing'; import { logger } from './logger'; import { validateJwtKeysAtStartup } from './auth/keys'; @@ -177,6 +178,7 @@ app.use('/api/billing', billingRouter); app.get( '/metrics', + metricsAuthGuard, asyncHandler(async (_req, res) => { res.set('Content-Type', registry.contentType); res.end(await registry.metrics()); diff --git a/src/middleware/metricsAuthGuard.ts b/src/middleware/metricsAuthGuard.ts new file mode 100644 index 0000000..b966955 --- /dev/null +++ b/src/middleware/metricsAuthGuard.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * Gates GET /metrics. When METRICS_TOKEN is set, a matching Bearer token or + * X-Metrics-Token header is required (401 otherwise). Without it, only + * loopback callers (127.0.0.1/::1) are allowed (403 for anyone else) so the + * scrape endpoint isn't exposed by default. + */ +export function metricsAuthGuard(req: Request, res: Response, next: NextFunction): void { + const metricsToken = process.env.METRICS_TOKEN?.trim() || null; + + if (metricsToken) { + const provided = + (req.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '') || + (req.headers['x-metrics-token'] as string | undefined) || + ''; + if (provided !== metricsToken) { + res + .status(401) + .set('WWW-Authenticate', 'Bearer realm="metrics"') + .json({ error: 'Unauthorized' }); + return; + } + next(); + return; + } + + const ip = (req.ip ?? '').replace('::ffff:', ''); + if (ip !== '127.0.0.1' && ip !== '::1') { + res.status(403).json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' }); + return; + } + next(); +} diff --git a/tests/metrics-access.test.ts b/tests/metrics-access.test.ts index 0c1aee8..e21721c 100644 --- a/tests/metrics-access.test.ts +++ b/tests/metrics-access.test.ts @@ -1,118 +1,84 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; +import { metricsAuthGuard } from '../src/middleware/metricsAuthGuard'; -function buildMetricsApp(metricsToken: string | null) { +function buildMetricsApp() { const app = express(); - app.set('trust proxy', true); - - app.get('/metrics', async (req, res) => { - try { - if (metricsToken) { - const provided = - (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '') || - (req.headers['x-metrics-token'] as string | undefined) || - ''; - if (provided !== metricsToken) { - res - .status(401) - .set('WWW-Authenticate', 'Bearer realm="metrics"') - .json({ error: 'Unauthorized' }); - return; - } - } else { - const ip = (req.ip ?? '').replace('::ffff:', ''); - if (ip !== '127.0.0.1' && ip !== '::1') { - res - .status(403) - .json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' }); - return; - } - } - res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); - res.end('# metrics'); - } catch (err) { - res.status(500).json({ error: 'internal' }); - } + app.get('/metrics', metricsAuthGuard, (_req, res) => { + res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.end('# metrics'); }); - return app; } -describe('metrics — token-protected mode', () => { +afterEach(() => { + delete process.env.METRICS_TOKEN; +}); + +describe('GET /metrics — token-protected mode', () => { const token = 'super-secret-metrics-token'; it('returns 401 when no credentials provided', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics'); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()).get('/metrics'); expect(res.status).toBe(401); expect(res.headers['www-authenticate']).toMatch(/Bearer/); }); it('returns 401 for wrong Bearer token', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token'); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()) + .get('/metrics') + .set('Authorization', 'Bearer wrong-token'); expect(res.status).toBe(401); }); it('returns 401 for wrong X-Metrics-Token header', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics').set('X-Metrics-Token', 'wrong-token'); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()).get('/metrics').set('X-Metrics-Token', 'wrong-token'); expect(res.status).toBe(401); }); it('serves metrics with correct Bearer token', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics').set('Authorization', `Bearer ${token}`); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()).get('/metrics').set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.text).toContain('# metrics'); }); it('serves metrics with correct X-Metrics-Token header', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics').set('X-Metrics-Token', token); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()).get('/metrics').set('X-Metrics-Token', token); expect(res.status).toBe(200); }); it('returns JSON error body on 401', async () => { - const app = buildMetricsApp(token); - const res = await request(app).get('/metrics'); + process.env.METRICS_TOKEN = token; + const res = await request(buildMetricsApp()).get('/metrics'); expect(res.body).toHaveProperty('error'); }); }); -describe('metrics — loopback-only mode (no token configured)', () => { +describe('GET /metrics — loopback-only mode (no token configured)', () => { it('returns 403 for non-loopback IP', async () => { - const app = buildMetricsApp(null); - const res = await request(app).get('/metrics').set('X-Forwarded-For', '203.0.113.5'); + const res = await request(buildMetricsApp()).get('/metrics').set('X-Forwarded-For', '203.0.113.5'); // With trust proxy enabled, the spoofed IP triggers 403. // Without it, supertest connects via loopback and gets 200. expect([200, 403]).toContain(res.status); }); it('serves metrics from loopback without any token', async () => { - const app = buildMetricsApp(null); - const res = await request(app).get('/metrics'); + const res = await request(buildMetricsApp()).get('/metrics'); expect(res.status).toBe(200); }); it('returns 403 error body for non-loopback access', async () => { const app = express(); app.set('trust proxy', true); - app.get('/metrics', async (req, res) => { - try { - const ip = (req.ip ?? '').replace('::ffff:', ''); - if (ip !== '127.0.0.1' && ip !== '::1') { - res - .status(403) - .json({ error: 'Metrics endpoint requires METRICS_TOKEN for remote access' }); - return; - } - res.end('# metrics'); - } catch (err) { - res.status(500).json({ error: 'internal' }); - } + app.get('/metrics', metricsAuthGuard, (_req, res) => { + res.end('# metrics'); }); const res = await request(app).get('/metrics').set('X-Forwarded-For', '203.0.113.5');