Skip to content

Latest commit

 

History

History
402 lines (286 loc) · 21.7 KB

File metadata and controls

402 lines (286 loc) · 21.7 KB

Summary

This PR implements a Prometheus-compatible observability stack for the Finchippay backend, addressing the complete absence of production metrics (Issue #79). It adds a GET /metrics endpoint exposing request rates, P95 latency, Horizon API call volumes, active webhook SSE streams, and default Node.js runtime metrics — all in Prometheus text format. The endpoint is protected by Bearer token authentication via a METRICS_TOKEN environment variable. A pre-built Grafana dashboard and a ready-to-run Prometheus + Grafana Docker Compose stack ship alongside the implementation so operators can visualize data within minutes of merging.

Closes #79


Type of change

  • New feature (Prometheus metrics exposition + observability stack)
  • Tests (5 new unit tests for METRICS_TOKEN validation)
  • Documentation (ENV.md, inline JSDoc, dashboard JSON, Prometheus config)
  • Configuration change (docker-compose.yml, docker-compose.prod.yml)

Architecture

┌──────────────┐     scrape /metrics      ┌─────────────┐     query     ┌──────────┐
│  Prometheus  │ ◄─────────────────────► │   Backend   │              │ Grafana  │
│  :9090       │     Bearer <token>       │   Express   │              │ :3001    │
└──────────────┘                          │   :4000     │              └──────────┘
                                          └─────────────┘
                                                 │
                                   ┌─────────────┼─────────────┐
                                   │             │             │
                              trackHttpMetrics  stellarService  webhookService
                              (every request)   (horizon calls)  (SSE streams)

Data flow

  1. trackHttpMetrics middleware (mounted immediately after helmet, before routes) attaches a finish event listener on every request. When the response completes, it records:

    • Duration via http_request_duration_seconds histogram (high-resolution process.hrtime.bigint())
    • Count via http_requests_total counter labeled by method, normalised route, and status code
  2. stellarService.js increments horizon_requests_total on every Horizon API call (loadAccount, getPayments, getTransaction) tagged success or error.

  3. webhookService.js increments horizon_requests_total on SSE stream starts and errors, and updates active_webhook_streams gauge whenever a stream is created or torn down.

  4. GET /metrics renders the prom-client registry as Prometheus text format. Protected by requireMetricsToken middleware (Bearer auth via METRICS_TOKEN).


Files Changed

New files (6)

File Lines Purpose
backend/src/services/metricsService.js 101 Creates a dedicated prom-client Registry with finchippay_ prefix. Registers 4 custom metrics + collectDefaultMetrics() for Node.js runtime instrumentation. Exports getMetrics() and getContentType() for use by the route handler.
backend/src/middleware/metrics.js 108 Exports trackHttpMetrics (duration + counter middleware) and requireMetricsToken (Bearer auth gate for /metrics). Includes normalisedRoute() to collapse dynamic Express paths like /api/payments/:id to stable label values.
backend/src/routes/metrics.js 40 Express router exposing GET /metrics. Calls requireMetricsToken before the handler. Renders the Prometheus text payload with correct Content-Type.
docs/prometheus.yml 31 Prometheus scrape config targeting backend:4000/metrics every 15 s. Includes commented instructions for adding Bearer auth in production.
docs/grafana-dashboard.json 500+ 8-panel Grafana dashboard (schema v39) covering HTTP rate, P50/P95 latency, success rate, 5xx rate, webhook streams, heap memory, Horizon rate, and CPU usage.
docs/grafana-datasources.yml 17 Grafana provisioning config that auto-wires the Prometheus datasource with uid prometheus.

Modified files (11)

File Change summary
backend/package.json Added prom-client dependency
backend/package-lock.json Lockfile updated (651 new packages)
backend/src/server.js Imported trackHttpMetrics and metricsRoutes; mounted middleware after helmet + before pinoHttp; registered /metrics route after all API routes
backend/src/services/stellarService.js Imported metricsService; added horizonRequestsTotal.inc() at 5 instrumentation points: loadAccount success/error, getPayments success/error (via try/catch), getTransaction success/error
backend/src/services/webhookService.js Imported metricsService; added horizonRequestsTotal.inc({ operation: "startSSE" }) before the stream-exists check; added horizonRequestsTotal.inc({ operation: "sse", status: "error" }) in the onerror callback; set activeWebhookStreams gauge on stream creation and teardown
backend/src/config/validateEnv.js Added METRICS_TOKEN validation: optional but if set must be ≥ 16 characters; generates actionable error message
backend/__tests__/validateEnv.test.js 5 new test cases: absent token → no error; valid ≥16 chars → no error; too-short → flagged; exactly 16 chars → no error; whitespace-only → treated as absent
docker-compose.yml Added prometheus (port 9090, --web.enable-lifecycle) and grafana (port 3001, admin/admin) services with named volumes prometheus_data and grafana_data
docker-compose.prod.yml Same as above but Prometheus + Grafana bind to 127.0.0.1 only; Prometheus uses 30-day retention; Grafana credentials via ${GRAFANA_ADMIN_USER} / ${GRAFANA_ADMIN_PASSWORD} env vars
ENV.md Added METRICS_TOKEN row to the backend env vars table (optional, ≥ 16 chars, openssl rand -hex 32)
package-lock.json (root) Updated

Custom Metrics — Detailed Specification

1. http_requests_total (Counter)

labels: method, route, status_code

Incremented on every completed HTTP request. The route label uses the normalised Express pattern (e.g. GET /api/payments/:id) rather than the raw URL path, preventing unbounded cardinality from dynamic segments like account IDs or payment hashes.

Normalisation logic (normalisedRoute in middleware/metrics.js):

  • Reads req.route.path (resolved after Express matches the route) plus req.baseUrl for mounted sub-routers
  • Falls back to req.path for unmatched routes (e.g. 404s)
  • Produces labels like GET /health, POST /api/payments/:id, GET /federation

2. http_request_duration_seconds (Histogram)

labels: method, route
buckets: [0.05, 0.1, 0.5, 1, 2, 5]

Measured from middleware entry to the finish event on res, using process.hrtime.bigint() for nanosecond precision. The histogram buckets are tuned for API response times:

  • 50 ms: sub-100 ms fast-path responses (cached account lookups, health checks)
  • 100 ms: typical JSON API response
  • 500 ms: slower queries (Horizon payment history without cache)
  • 1–2 s: long-tail external API calls
  • 5 s: timeout boundary

Query for P95 in PromQL: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[2m]))

3. horizon_requests_total (Counter)

labels: operation, status

Tracks all outbound calls to the Stellar Horizon API. Instrumentation points:

Operation Location Success condition Error condition
loadAccount stellarService.getAccount() Horizon returns account data 404 or network error after retries
getPayments stellarService.getPayments() query.call() succeeds Exception from withTimeoutAndRetry
getTransaction stellarService.getPayments() (per-payment memo fetch) Transaction object returned Exception caught, memo skipped
startSSE webhookService.startMonitoring() Called (always succeeds synchronously) N/A
sse webhookService.startMonitoring() onerror callback N/A SSE stream error

4. active_webhook_streams (Gauge)

labels: (none)

Reflects activeStreams.size — the number of concurrently open Horizon SSE connections. Updated:

  • Incremented: after activeStreams.set() in startMonitoring()
  • Decremented: after activeStreams.delete() in the onerror callback

Default Node.js metrics (collectDefaultMetrics)

Registered with prefix: "finchippay_" via prom-client's built-in collector. Exposes:

Metric Description
finchippay_process_cpu_user_seconds_total User CPU time
finchippay_process_cpu_system_seconds_total System CPU time
finchippay_process_heap_bytes V8 heap size
finchippay_process_resident_memory_bytes RSS
finchippay_nodejs_eventloop_lag_seconds Event loop lag (P50/P95/P99)
finchippay_nodejs_gc_duration_seconds GC pause duration
... and more file descriptors, active handles, heap spaces

Authentication Design

Token-based Bearer auth (requireMetricsToken middleware)

Authorization: Bearer <METRICS_TOKEN>

Three modes of operation:

Mode METRICS_TOKEN set? Behavior
Local dev No /metrics is open; a console.warn is emitted once at startup reminding the developer to set it in production
Test No (NODE_ENV=test) Open, but the warning is suppressed to keep test output clean
Production Yes (≥ 16 chars) 401 Unauthorized returned unless the Authorization: Bearer <token> header matches exactly

Error responses:

  • Missing/invalid header → 401 + WWW-Authenticate: Bearer realm="metrics" + JSON {"error": "Unauthorized: missing or invalid Authorization header..."}
  • Wrong token → 401 + JSON {"error": "Unauthorized: invalid metrics token."}

Startup validation (validateEnv.js)

METRICS_TOKEN is optional (no startup crash if missing). When present:

  • Must be a string of ≥ 16 characters (non-whitespace)
  • Shorter tokens cause process.exit(1) with: METRICS_TOKEN must be at least 16 characters, got N. Generate a secure token: openssl rand -hex 32
  • Whitespace-only is treated as absent (development fallback)

Prometheus scraper auth

The docs/prometheus.yml config file ships without the authorization block (open access for local dev). Comments in the file instruct operators to uncomment and configure the block for production:

# authorization:
#   type: "Bearer"
#   credentials: "YOUR_METRICS_TOKEN"

Note: Prometheus does not perform env-var substitution in config files — the token must be inlined or injected via a pre-processing step (e.g. envsubst in a custom entrypoint). The comments make this explicit.


Grafana Dashboard

Title: Finchippay Solution — Backend Metrics UID: finchippay-backend-metrics Refresh: 30 s Default range: Last 1 hour

Panel layout (24-column grid, 8 panels)

Row Panel Type Size PromQL
1 HTTP Request Rate Timeseries (line) 12×8 rate(http_requests_total[1m])
1 HTTP Request Duration (P50 & P95) Timeseries (line) 12×8 histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[2m])) + P50 variant
2 Success Rate (2xx) Stat (background) 6×6 sum(rate(http_requests_total{status_code=~"2.."}[5m])) / sum(rate(http_requests_total[5m])) * 100
2 5xx Error Rate Stat (background) 6×6 sum(rate(http_requests_total{status_code=~"5.."}[5m]))
2 Active Webhook Streams Stat (background) 6×6 active_webhook_streams
2 Heap Memory Stat (background) 6×6 finchippay_process_heap_bytes
3 Horizon Request Rate Timeseries (line) 12×8 rate(horizon_requests_total[1m])
3 CPU Usage Timeseries (line) 12×8 rate(finchippay_process_cpu_user_seconds_total[1m]) * 100 + system variant

All stat panels use background color mode with area sparklines for at-a-glance health assessment. Timeseries panels use smooth line interpolation with table legends showing mean and max.

The dashboard is provisioned into Grafana automatically via volume mount (/etc/grafana/provisioning/dashboards/finchippay.json), so it appears immediately after docker compose up — no manual import needed.


Design Decisions

1. Why process.hrtime.bigint() instead of Date.now()?

Date.now() has millisecond resolution and is subject to system clock adjustments (NTP skew, manual changes). process.hrtime.bigint() provides nanosecond-precision monotonic time — essential for accurate latency histograms where 50 ms buckets need meaningful data. The conversion Number(bigint) / 1e9 avoids floating-point drift.

2. Why route normalisation via req.route.path?

Without normalisation, a Prometheus scrape would produce separate time-series for every unique URL path (e.g. /api/payments/GABC...123, /api/payments/GXYZ...789), quickly reaching hundreds of thousands of series and overwhelming Prometheus storage. Express stores the matched route pattern on req.route.path after resolution — using this yields stable labels like GET /api/payments/:id.

3. Why a dedicated prom-client Registry instead of the default global?

A dedicated Registry instance (not promClient.register) isolates Finchippay metrics from any other libraries that might register metrics on the global default. The prefix: "finchippay_" applied to collectDefaultMetrics further prevents naming collisions with other services on the same Prometheus instance.

4. Why mount trackHttpMetrics before pinoHttp?

Ordering matters for accurate latency measurement:

  1. helmet — sets security headers (no measurable impact)
  2. trackHttpMetrics — starts the high-resolution timer immediately
  3. pinoHttp — request logging (excluded from timing)
  4. express.json — body parsing (included in timing, as it's part of request processing)
  5. Routes — business logic (included in timing)

This ensures the duration captures actual request processing time, not just route handler execution.

5. Why the /metrics route is subject to the global rate limiter?

The global limiter (100 req / 15 min per IP) is adequate for typical Prometheus scrape intervals (15–60 s ≈ 15–60 req / 15 min). Moving the route above the limiter would require refactoring the middleware pipeline and is unnecessary for standard configurations. A comment in routes/metrics.js documents this trade-off for operators who scrape more aggressively.

6. Why no test for the /metrics endpoint itself?

The existing backend test suite uses unit + property-based tests (Jest, fast-check) rather than HTTP-level integration tests (supertest). The validateEnv.test.js tests cover the METRICS_TOKEN validation logic. An HTTP-level integration test for the /metrics endpoint would be a valuable follow-up but is consistent with the current testing strategy to defer.


Testing

New tests added

Test File What it verifies
METRICS_TOKEN absent → no error validateEnv.test.js Optional env var doesn't block startup
METRICS_TOKEN ≥ 16 chars → no error validateEnv.test.js Valid tokens pass validation
METRICS_TOKEN too short → error validateEnv.test.js Short tokens are flagged with actionable message
METRICS_TOKEN exactly 16 chars → no error validateEnv.test.js Boundary case: minimum length accepted
METRICS_TOKEN whitespace-only → no error validateEnv.test.js Empty/whitespace treated as absent (dev fallback)

Existing tests (regression)

All 107 existing backend tests continue to pass without modification. The metrics instrumentation is purely additive — it adds counter/gauge increments after existing logic without altering any return values, error handling, or control flow.


How to Test

Local development (Docker Compose)

# 1. Start the full stack (backend, frontend, Prometheus, Grafana)
docker compose up

# 2. Verify the /metrics endpoint is live (open in dev)
curl -s http://localhost:4000/metrics | head -20
# Expected output:
# # HELP finchippay_process_cpu_user_seconds_total ...
# # TYPE finchippay_process_cpu_user_seconds_total counter
# # HELP http_requests_total Total number of HTTP requests...
# # TYPE http_requests_total counter
# ...

# 3. Generate some traffic to populate metrics
curl http://localhost:4000/health
curl http://localhost:4000/api/auth/nonce  # or any valid endpoint

# 4. Open Grafana at http://localhost:3001
#    Credentials: admin / admin
#    Dashboard: "Finchippay Solution — Backend Metrics"
#    You should see the HTTP Request Rate panel showing recent requests.

# 5. Verify Prometheus is scraping successfully
#    Open http://localhost:9090/targets
#    The "finchippay-backend" target should show State: UP

With METRICS_TOKEN (simulates production)

# Start backend with a token
cd backend
METRICS_TOKEN=my-production-token-32chars npm start

# Without token → 401
curl -i http://localhost:4000/metrics
# HTTP/1.1 401 Unauthorized
# www-authenticate: Bearer realm="metrics"
# {"error":"Unauthorized: missing or invalid Authorization header..."}

# With correct token → 200
curl -H "Authorization: Bearer my-production-token-32chars" http://localhost:4000/metrics

# With wrong token → 401
curl -H "Authorization: Bearer wrong-token" http://localhost:4000/metrics
# {"error":"Unauthorized: invalid metrics token."}

Verify individual metrics

# HTTP request counter — look for health check requests
curl -s http://localhost:4000/metrics | grep 'http_requests_total{'

# Horizon request counter — make some Stellar calls first
curl -s http://localhost:4000/metrics | grep 'horizon_requests_total{'

# Active webhook streams — should be 0 initially
curl -s http://localhost:4000/metrics | grep 'active_webhook_streams'

# Default Node.js metrics — prefixed with finchippay_
curl -s http://localhost:4000/metrics | grep 'finchippay_process_heap_bytes'

CI Verification

All CI jobs that are affected by this change pass locally:

Check Status Details
Backend lint (eslint) ✅ 0 errors All new .js files follow eslint:recommended + prettier
Backend tests (jest) ✅ 12/12 suites, 107/107 tests Including 5 new validateEnv tests
Docker Compose config validation ✅ Both valid docker-compose.yml and docker-compose.prod.yml

Jobs unaffected (no changes in these domains):

Job Reason untouched
Frontend (lint, type-check, tests, build) No frontend files modified
Contracts (cargo check, cargo test, WASM build) No Rust files modified
E2E (Playwright) No UI changes; backend API surface extended but not altered

Backward Compatibility

  • ✅ No breaking changes. All existing routes, middleware, error handling, and response formats are preserved.
  • ✅ Optional env var. METRICS_TOKEN is not required; the server starts and operates normally without it.
  • ✅ Additive instrumentation. Counter and gauge increments are side-effect-free — they do not alter return values, throw exceptions, or change control flow.
  • ✅ No new required dependencies for existing services. prom-client is only needed in the backend; frontend and contracts are unaffected.
  • ✅ Docker Compose additive. The prometheus and grafana services are added alongside existing services; docker compose up backend frontend still works to run only the core stack.

Security Considerations

  1. Metrics do not expose sensitive data. The /metrics endpoint returns aggregate counters and histograms — no Stellar public keys, transaction hashes, user data, or request payloads are exposed.
  2. Token-based access control. When METRICS_TOKEN is set in production, only Prometheus (or an authorized scraper) can access the endpoint. The token is compared with a constant-time-safe string comparison.
  3. No token in logs. The METRICS_TOKEN value never appears in logs, error messages, or response bodies.
  4. Prometheus + Grafana bind to localhost in production. The docker-compose.prod.yml uses 127.0.0.1 port bindings so the monitoring stack is not exposed to the public internet.

Acceptance Criteria Checklist

  • GET /metrics returns Prometheus text format (verified via curl)
  • All four custom metrics collected:
    • http_requests_total{method, route, status_code}
    • http_request_duration_seconds{method, route} (buckets: 0.05, 0.1, 0.5, 1, 2, 5)
    • horizon_requests_total{operation, status}
    • active_webhook_streams
  • Endpoint protected by METRICS_TOKEN env var (Bearer auth)
    • Open when unset (dev mode)
    • 401 when token is set and missing/incorrect
    • 200 when correct token provided
  • docker-compose.yml adds Prometheus + Grafana stack for local dev
  • Dashboard JSON exported to docs/grafana-dashboard.json

Follow-up Work (out of scope for this PR)

  • Add a supertest-based integration test for GET /metrics verifying Prometheus format and auth gating
  • Add Prometheus alerting rules in docs/prometheus-alerts.yml (e.g. high 5xx rate, high P95 latency)
  • Add METRICS_TOKEN to the CI backend/.env.example and GitHub Actions secrets documentation
  • Consider moving /metrics above the global rate limiter for operators who scrape at sub-15s intervals