Live Demo: http://34.82.82.190:5173
Gateway API: http://34.82.82.190:3000/health
A fully functional API gateway built from scratch. Proxies requests, enforces rate limiting via token bucket or sliding window algorithms, handles JWT auth, circuit breaking, retry with exponential backoff, multi-tenancy, distributed tracing, Prometheus metrics, a live analytics dashboard, and Kafka-based async request logging for durable, decoupled observability.
docker compose up -d --build
# wait ~30s for DB init
curl http://localhost:3000/healthServices after startup:
| Service | URL |
|---|---|
| Gateway | http://localhost:3000 |
| Dashboard | http://localhost:5173 |
| Grafana | http://localhost:3001 (admin / pulse) |
| Prometheus | http://localhost:9090 |
| Jaeger UI | http://localhost:16686 |
| Kafka broker | localhost:9092 |
| Admin API | http://localhost:3000/admin (X-Api-Key: admin-secret-key) |
Client → PulseAPI Gateway (Node.js/Express)
│
├── Token Bucket Rate Limiter ──┐
├── Sliding Window Rate Limiter ─┤→ Redis
├── Circuit Breaker state ───────┘
├── JWT Auth Middleware
├── Round-Robin Load Balancer
├── Retry + Exponential Backoff
│
├── upstream-1 (5% fail rate)
├── upstream-2 (2% fail rate)
└── upstream-3 (stable)
Kafka topic ← Request events (async publish, off critical path)
└── kafka-consumer → PostgreSQL (batch INSERT, 500 records or 1s)
WebSocket → Dashboard (live stats, 1s push)
Jaeger ← Distributed traces (OTel)
Prometheus ← /metrics scraped every 15s
Grafana → 13-panel dashboard (auto-provisioned)
Request logs are published to a Kafka topic (request-logs) instead of writing directly to Postgres. A separate kafka-consumer service subscribes and batch-inserts to Postgres.
Why async via Kafka vs. direct write?
- Gateway latency is fully decoupled from Postgres write throughput
- Kafka retains messages during DB maintenance windows — no logs dropped
- Fan-out: add analytics consumers, SIEM feeds without touching the gateway
- Consumer lag is a Kafka-native observable metric
- Historical replay: reprocess logs against a new schema without rerunning load tests
The gateway falls back to direct Postgres writes automatically when KAFKA_BROKERS is not set — local dev works without a Kafka cluster.
/metrics is exposed via prom-client and scraped by Prometheus every 15 seconds. The Grafana dashboard provisions automatically on first boot — no manual setup required.
Metrics exposed:
| Metric | Type | Description |
|---|---|---|
pulseapi_http_requests_total |
Counter | Request count by method, route, status code |
pulseapi_http_request_duration_ms |
Histogram | Latency distribution (p50/p95/p99 via histogram_quantile) |
pulseapi_upstream_errors_total |
Counter | Upstream failures by type (5xx, timeout, circuit_open) |
pulseapi_rate_limit_rejections_total |
Counter | 429s by route and algorithm |
pulseapi_circuit_breaker_state |
Gauge | Per-upstream state: 0=closed, 1=half_open, 2=open |
pulseapi_proxy_retries_total |
Counter | Retry attempts by route and upstream |
pulseapi_ws_connections_active |
Gauge | Active dashboard WebSocket connections |
pulseapi_routes_active |
Gauge | Routes currently loaded in the gateway |
Key PromQL queries:
# p99 latency
histogram_quantile(0.99, sum(rate(pulseapi_http_request_duration_ms_bucket[5m])) by (le))
# error rate %
100 * sum(rate(pulseapi_http_requests_total{status_code=~"5.."}[1m])) / sum(rate(pulseapi_http_requests_total[1m]))
# live request rate by route
sum(rate(pulseapi_http_requests_total[1m])) by (route_id)
Every request gets a trace ID propagated through gateway to upstream. Trace ID stored in requests.trace_id for cross-signal correlation.
SELECT * FROM requests WHERE trace_id = 'abc123...';View traces at http://localhost:16686
# install k6
k6 run load-tests/k6.jsMeasured results at 200 VUs:
| Metric | Value |
|---|---|
| Sustained throughput | 207 req/s |
| p50 latency | 282ms |
| p95 latency | 407ms |
| p99 latency | 881ms (via histogram_quantile in Prometheus) |
| Error rate | 0.0% (enterprise tenant, 1000 req/s quota) |
Implemented in gateway/src/plugins/rateLimit.js using Redis Lua scripts for atomicity — no race conditions under concurrent load.
# trigger rate limiting (10 req/s default limit)
for i in {1..20}; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/upstream1/test; doneState stored in Redis, shared across all gateway instances. Opens after 5 consecutive 5xx failures, auto-recovers after 30s via HALF_OPEN probe.
# check all circuit states
curl -H "X-Api-Key: admin-secret-key" http://localhost:3000/admin/circuit-breakers
# manually reset a circuit
curl -X POST http://localhost:3000/admin/circuit-breakers/reset \
-H "X-Api-Key: admin-secret-key" -H "Content-Type: application/json" \
-d '{"upstream":"http://upstream1:4001"}'curl -X POST http://localhost:3000/admin/routes \
-H "X-Api-Key: admin-secret-key" -H "Content-Type: application/json" \
-d '{
"id": "my-service",
"pathPrefix": "/my-service",
"upstreams": [{"url": "http://my-backend:8080", "weight": 1}],
"plugins": {
"rateLimit": {"enabled": true, "requestsPerSecond": 100, "algorithm": "token-bucket"},
"auth": {"enabled": false}
}
}'
# route is live immediately — no restart neededTOKEN=$(curl -s -X POST http://localhost:3000/admin/token \
-H "X-Api-Key: admin-secret-key" | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/secure/dataTenant API keys stored as SHA-256 hashes — raw keys never persisted.
| Tier | Rate Limit | Burst |
|---|---|---|
| Free | 10 req/s | 20 |
| Pro | 100 req/s | 200 |
| Enterprise | 1000 req/s | 2000 |
# create tenant
curl -X POST http://localhost:3000/admin/tenants \
-H "X-Api-Key: admin-secret-key" -H "Content-Type: application/json" \
-d '{"id":"acme","name":"Acme Corp","tier":"pro"}'
# generate API key (shown once, hash stored)
curl -X POST http://localhost:3000/admin/tenants/acme/keys \
-H "X-Api-Key: admin-secret-key"
# view usage
curl -H "X-Api-Key: admin-secret-key" http://localhost:3000/admin/tenants/acme/usagecurl -H "X-Api-Key: admin-secret-key" \
"http://localhost:3000/admin/analytics?window=1+hour"Direct SQL:
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY latency_ms) p50,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) p95,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY latency_ms) p99
FROM requests
WHERE timestamp > NOW() - INTERVAL '1 hour';| Algorithm | Best For | Tradeoff |
|---|---|---|
| Token Bucket | Burst-tolerant APIs | Brief bursts above limit allowed |
| Sliding Window | Strict SLA enforcement | No burst, higher Redis ops |
Configure per-route via plugins.rateLimit.algorithm: "token-bucket" | "sliding-window".
See docs/decisions/ for rationale on key engineering choices:
| Component | Technology | Why |
|---|---|---|
| Gateway | Node.js + Express | Async I/O, event loop handles concurrency |
| Rate Limiting | Redis + Lua | Atomic token bucket, shared across instances |
| Circuit Breaker | Redis | Distributed state, consistent across instances |
| Metrics | Prometheus + prom-client | Industry standard, histogram_quantile for real p99 |
| Dashboards | Grafana | 13-panel dashboard, auto-provisioned |
| Tracing | OpenTelemetry + Jaeger | Industry standard, zero-code instrumentation |
| Auth | jsonwebtoken | Industry standard JWT |
| Analytics DB | PostgreSQL | PERCENTILE_CONT for real p99 queries |
| Dashboard | React + Recharts | Live WebSocket charts |
| Load Tests | k6 | Scriptable, CI-friendly |