Stack: Flask · Peewee ORM · PostgreSQL · Redis · Docker · Locust
A URL shortener API with comprehensive caching, metrics, and load testing infrastructure.
- Docker and Docker Compose installed
- Git installed
git clone <repo-url>
cd QuadroURL
cp .env.example .envEdit .env if needed — defaults work out of the box for local development.
docker compose up --buildcurl http://127.0.0.1/health
# → {"status": "ok"}docker compose up --buildThis starts 7 services: app (3 replicas), PostgreSQL, Redis, Nginx, Prometheus, Grafana, and Loki+Promtail.
Requires a running PostgreSQL and Redis instance.
# Install dependencies
uv sync
# Start the app (Flask dev server on port 5000)
uv run run.py
# Or start with gunicorn (port 8000, matching Docker)
uv run gunicorn --bind 0.0.0.0:8000 --workers 4 --threads 4 --worker-class gthread --timeout 5 run:appSet FLASK_DEBUG=true in .env for debug mode.
| Service | URL | Credentials |
|---|---|---|
| App (via Nginx) | http://127.0.0.1 | - |
| App (direct, dev) | http://localhost:5000 | - |
| Prometheus | http://localhost:9090 | - |
| Grafana | http://localhost:3000 | admin / admin |
| Loki | http://localhost:3100 | - |
docker compose downTo also remove volumes (database data):
docker compose down -vGET /health
Response: 200 OK
{"status": "ok"}POST /users
Content-Type: application/json
Request body:
{
"username": "testuser",
"email": "testuser@example.com"
}Response: 201 Created
{
"id": 1,
"username": "testuser",
"email": "testuser@example.com",
"created_at": "2026-04-03T12:00:00"
}Errors: 400 — invalid JSON, missing fields, or duplicate username/email.
GET /users?page=1&per_page=20
| Param | Type | Default | Description |
|---|---|---|---|
page |
int | 1 |
Page number (1-indexed) |
per_page |
int | 20 |
Items per page |
Response: 200 OK
{
"kind": "list",
"sample": [
{
"id": 1,
"username": "testuser",
"email": "testuser@example.com",
"created_at": "2026-04-03T12:00:00"
}
]
}GET /users/<id>
Response: 200 OK — single user object.
Errors: 404 — user not found.
PUT /users/<id>
Content-Type: application/json
Request body:
{
"username": "updated_name"
}Response: 200 OK — updated user object.
Errors: 400 — invalid JSON. 404 — user not found.
DELETE /users/<id>
Response: 200 OK
{}POST /users/bulk
Content-Type: multipart/form-data
| Field | Type | Description |
|---|---|---|
file |
file | CSV file with username and email columns |
Response: 200 OK
{"imported": 25}Note: Drops and recreates the entire User table on each import.
POST /urls
Content-Type: application/json
Request body:
{
"user_id": 1,
"original_url": "https://example.com/long-page",
"title": "Example Page"
}Response: 201 Created
{
"id": 1,
"user_id": 1,
"short_code": "k8Jd9s",
"original_url": "https://example.com/long-page",
"title": "Example Page",
"is_active": true,
"created_at": "2026-04-03T12:00:00",
"updated_at": "2026-04-03T12:00:00"
}Errors: 400 — invalid JSON, missing fields, or user not found.
GET /urls?offset=0&size=20&user_id=1&is_active=true
| Param | Type | Default | Description |
|---|---|---|---|
offset |
int | 0 |
Pagination offset |
size |
int | 20 |
Page size |
id |
int | — | Filter by URL id |
user_id |
int | — | Filter by user id |
short_code |
string | — | Filter by short code |
original_url |
string | — | Filter by original URL |
is_active |
string | — | "true" = active, else inactive |
Response: 200 OK
{
"kind": "list",
"sample": [
{
"id": 1,
"user_id": 1,
"short_code": "k8Jd9s",
"original_url": "https://example.com/long-page",
"title": "Example Page",
"is_active": true,
"created_at": "2026-04-03T12:00:00",
"updated_at": "2026-04-03T12:00:00"
}
]
}GET /urls/<id>
Response: 200 OK — single URL object.
Errors: 404 — URL not found.
PUT /urls/<id>
Content-Type: application/json
Request body:
{
"title": "Updated Title",
"is_active": false
}Response: 200 OK — updated URL object.
Errors: 400 — invalid JSON. 404 — URL not found.
DELETE /urls/<id>
Response: 200 OK
{}GET /urls/<short_code>/redirect
Response: 302 Found — redirects to original_url.
Errors: 404 — short code not found or URL inactive.
GET /r/<short_code>
Response: 200 OK
{
"url": "https://example.com/long-page",
"short_code": "k8Jd9s"
}Errors: 404 — short code not found or URL inactive.
GET /events?offset=0&size=20&user_id=1&event_type=click
| Param | Type | Default | Description |
|---|---|---|---|
offset |
int | 0 |
Pagination offset |
size |
int | 20 |
Page size |
url_id |
int | — | Filter by URL id |
user_id |
int | — | Filter by user id |
event_type |
string | — | Filter by type (created, updated, click) |
Response: 200 OK
[
{
"id": 1,
"url_id": 1,
"user_id": {"id": 1},
"event_type": "created",
"timestamp": "2026-04-03T12:00:00",
"details": {"short_code": "k8Jd9s", "original_url": "https://..."}
}
]POST /events
Content-Type: application/json
Request body:
{
"url_id": 1,
"user_id": 1,
"event_type": "click",
"details": {"source": "dashboard"}
}Response: 201 Created — event object.
Errors: 400 — invalid JSON, missing fields, URL not found, or user not found.
GET /metrics
Response: 200 OK
{
"system": {
"cpu_percent": 8.2,
"system_ram": {"used_gb": 6.4, "total_gb": 15.9},
"process_memory_mb": 91.7
},
"latency": {"avg_ms": 15.2, "p50_ms": 12.0, "p95_ms": 22.0, "p99_ms": 210.0, "max_ms": 450.0},
"traffic": {"total_requests": 847, "requests_per_second": 4.2, "top_endpoints": [{"endpoint": "POST /urls", "count": 120}]},
"errors": {"total_errors": 102, "error_rate_percent": 12.0, "by_status": {"400": 102}},
"saturation": {"active_requests": 1, "peak_active_requests": 3},
"uptime_seconds": 3600.0,
"recent_requests": [{"method": "POST", "path": "/urls", "status": 400, "latency_ms": 2.1}]
}GET /logs
Response: 200 OK — last 50 structured log records.
{
"logs": [
{
"timestamp": "2026-04-03T12:00:00+00:00",
"level": "INFO",
"logger": "app",
"message": "Health check requested",
"method": "GET",
"path": "/health",
"remote_addr": "127.0.0.1"
}
]
}GET /dashboard
Response: 200 OK — self-contained HTML page with live-updating charts (latency, errors, traffic, system resources) that polls /metrics and /logs every 3 seconds.
GET /prometheus-metrics
Response: 200 OK — Prometheus text exposition format. Metrics include:
| Metric | Type | Labels |
|---|---|---|
http_requests_total |
Counter | method, endpoint, status |
http_request_duration_seconds |
Histogram | method, endpoint |
http_requests_in_progress |
Gauge | — |
http_errors_total |
Counter | method, endpoint, status |
process_cpu_percent |
Gauge | — |
process_memory_mb |
Gauge | — |
GET /fail
Kills the process immediately. Used for chaos/crash testing. No response (process exits).
All errors return JSON:
| Status | Body |
|---|---|
400 |
{"error": "<description>"} |
404 |
{"error": "Not found"} |
422 |
{"error": "Unprocessable entity"} |
500 |
{"error": "<exception>"} |
Automates a clean, repeatable Locust benchmark sweep against the local Docker Compose stack.
# Default: 2000, 3000, 4000, 5000 users
./scripts/run_load_tests.sh
# Custom levels, spawn rate, peak time
./scripts/run_load_tests.sh --levels 500,1000,2000 --spawn-rate 50 --peak-time 120
# Multi-process mode (avoids generator bottleneck at high user counts)
./scripts/run_load_tests.sh --levels 3000,4000,5000 --processes -1
# Target a different host
./scripts/run_load_tests.sh --host http://127.0.0.1 --locust-file scripts/test_locust.pyFlags:
| Flag | Default | Description |
|---|---|---|
--levels |
2000,3000,4000,5000 |
Comma-separated user counts to test |
--spawn-rate |
100 |
Users spawned per second |
--peak-time |
60 |
Seconds of steady-state load after ramp-up |
--run-time |
auto | Fixed run-time per level (overrides auto-computed) |
--processes |
single | -1 for auto (one worker per CPU core) |
--host |
http://127.0.0.1 |
Target host |
--locust-file |
scripts/test_locust.py |
Locust test file |
How auto run-time works: Each level gets ceil(users / spawn-rate) + peak-time seconds, so higher user counts automatically get a longer test window instead of spending most of a fixed duration still ramping up.
What it does per level:
- Full Docker Compose reset (
down -v+up --build -d) - Waits for
/healthto pass - Runs Locust headless at the target concurrency
- Saves CSV results to
loadtests/results_<timestamp>/
Results are saved per-level with _stats.csv, _failures.csv, and _stats_history.csv suffixes.
A complete Terraform setup for load testing at ~13,000 req/s with zero cross-network data transfer costs.
Architecture:
┌───────────────────────── VPC (10.0.0.0/16) ─────────────────────────┐
│ │
│ Public Subnets: │
│ ┌────────────┐ ┌────────────────────────────────┐ │
│ │ ALB │────▶│ ECS Fargate Spot (4 tasks) │ │
│ └────────────┘ └───────────────┬────────────────┘ │
│ │ │
│ Private Subnets: │ │
│ ┌───────────────────┐ ┌────────▼─────────────┐ │
│ │ PostgreSQL (RDS) │ │ ElastiCache (Redis) │ │
│ └───────────────────┘ └──────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ EC2 (Locust load tester) - c6i.xlarge Spot│ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Estimated cost: $0.53/hour ($1.06 for a 2-hour test run).
# Initialize and create infrastructure
cd terraform
terraform init
terraform apply -auto-approve
# Push Docker image to ECR
./scripts/deploy.sh# SSH to loadtester and run 5000-user headless test for 10 minutes
./scripts/run_loadtest.shThis runs on the EC2 load tester inside the same VPC (zero network cost), targeting the ALB.
cd terraform
terraform destroy -auto-approveAlways destroy immediately after testing to avoid ongoing charges.
┌─────────────┐
│ User │
└──────┬──────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ app-network │
│ ┌─────────┐ ┌───────────┐ ┌─────────────────┐ │
│ │ nginx │─────▶│ app │─────▶│ postgres │ │
│ │ :80 │ │ (3 pods) │ │ :5432 │ │
│ └─────────┘ └───────────┘ └─────────────────┘ │
│ │ ▲ │
│ │ │ │
│ ▼ │ │
│ ┌───────────┐ │ │
│ │ redis │────────────┘ │
│ │ :6379 │ │
│ └───────────┘ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │prometheus │ │ grafana │ │ loki │ │
│ │ :9090 │ │ :3000 │ │ :3100 │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
- Redis — L2 cache between app replicas to prevent redundant DB calls. L1 (in-process LRU dict) avoids Redis latency for hot keys. Anti-stampede strategies: single-flight deduplication, probabilistic early expiration, negative caching, TTL jitter.
- Nginx — Reverse proxy and load balancer across app replicas. Connection timeouts (5s connect, 10s read) prevent slow clients from holding connections.
- Docker Compose — Containerized orchestration with health checks, restart policies, and persistent volumes for PostgreSQL, Redis, and Grafana.
- Locust — Python-native load testing (same stack as the app). Weighted task distribution simulates realistic user behavior.
Access at http://localhost:3000 (admin/admin). The pre-built "Golden Signals" dashboard shows:
- Latency — avg, p50, p95, p99 response times
- Traffic — requests per second, top endpoints by volume
- Errors — error rate, error breakdown by status code
- Saturation — active requests, peak concurrent requests
- System — CPU, memory, process RSS
Scrapes app:8000/prometheus-metrics every 5 seconds. Access at http://localhost:9090.
Structured JSON logs are collected by Promtail and stored in Loki. Query via Grafana's Explore view or the /logs API endpoint.
A background monitor polls /health every 60 seconds. Sends Discord webhook alerts on:
- Service Down — health check fails or returns non-200
- Service Recovered — health check returns to healthy
Configure via DISCORD_WEBHOOK_URL and APP_URL in .env.
# Quick check
curl http://127.0.0.1/health
# Detailed metrics
curl http://127.0.0.1/metrics
# Recent logs
curl http://127.0.0.1/logs- Confirm with
GET /health - Check
GET /logsfor recent exceptions - Check
GET /metricsfor CPU/memory spikes - Verify PostgreSQL is running and credentials match
- Restart app container if hung
- Restart PostgreSQL if connections are failing
- If a bad deploy caused it, roll back
- Confirm
GET /healthreturns{"status": "ok"}
- Record incident start/end times
- Capture root cause from logs, deploy history, or DB state
- Update incident ticket with the fix
- Add prevention actions if user-facing
- Call
GET /healthand capture body - Check
GET /logsfor exceptions - Check PostgreSQL reachability
- Review recent deploys or config changes
- Restore last known good config, re-test
/health
CI: GitHub Actions runs pytest on every push/PR. Deployment is blocked if tests fail.
Pre-deploy:
uv run pytestManual rollback:
- Identify last good commit SHA
git revertor re-deploy that commit- Verify
GET /healthreturns healthy
- Primary: currently assigned on-call engineer
- Secondary: any teammate with deploy access
- If outage exceeds 30 minutes, open a GitHub issue titled
[INCIDENT] short summarywith dashboard screenshots and logs
uv sync
uv run run.py- Confirm PostgreSQL is running
- Check
.envvalues:DATABASE_HOST,DATABASE_PORT,DATABASE_USER,DATABASE_PASSWORD,DATABASE_NAME - Create the database:
createdb hackathon_db
- Verify app process is running
- Check
GET /logsfor stack traces - Check DB connectivity
- If only non-critical downstreams fail, the app degrades gracefully
400responses with{"error": "..."}indicate bad input- Check
user_idis an integer (not a string) - Check required fields are present (
username/emailfor users,user_id/original_url/titlefor URLs)
- Confirm incident with
GET /health - Collect evidence from
GET /logsandGET /metrics - Identify whether failure is app-level, DB-level, or request-schema-level
- Apply targeted recovery (restart app, recover DB, rollback bad deploy, or fix request)
- Verify recovery by re-running failing request and
/health - Record timeline and root cause
Reported issue: Users report intermittent URL creation failures.
- Dashboard (
GET /dashboard): Error rate jumped from 0% to ~12% - Error Breakdown: 85% are
400 Bad Request, 0% are500— server is healthy - Top Endpoints:
POST /urlsis the busiest - Logs (
GET /logs): Repeateduser_id must be an integerwarnings - Root cause: Client sending
"user_id": "1"(string) instead of"user_id": 1(integer) - Fix: Client corrects payload format. No server changes needed.
Time to diagnosis: under 2 minutes using only the dashboard and logs.
| Script | Purpose |
|---|---|
scripts/test_locust.py |
Locust load test — weighted tasks simulating real user behavior |
scripts/run_load_tests.sh |
Automated sweep — Docker reset between levels, CSV results per level |
scripts/generate_traffic.py |
Traffic generator for populating Grafana dashboards |
scripts/load_csv.py |
Bulk load CSV seed data into PostgreSQL |
terraform/scripts/deploy.sh |
AWS deploy — Terraform apply + ECR push + ECS force-deploy |
terraform/scripts/run_loadtest.sh |
AWS load test — SSH to EC2, run headless Locust, download results |
QuadroURL/
├── app/
│ ├── __init__.py # App factory (create_app), logging, middleware, health endpoint
│ ├── database.py # DatabaseProxy, BaseModel, PooledPostgresqlDatabase
│ ├── cache.py # L1/L2 cache with anti-stampede (single-flight, early expiry, negative cache)
│ ├── log_store.py # In-memory log buffer
│ ├── metrics_store.py # In-memory golden signals (latency, traffic, errors, saturation)
│ ├── models/
│ │ ├── user.py # User(id, username, email, created_at)
│ │ ├── url.py # Url(id, user FK, short_code, original_url, title, is_active, timestamps)
│ │ └── event.py # Event(id, url FK, user FK, event_type, timestamp, details)
│ ├── routes/
│ │ ├── users.py # CRUD + bulk CSV import
│ │ ├── urls.py # CRUD + short-code redirect
│ │ ├── events.py # List + create events
│ │ ├── metrics.py # GET /metrics (golden signals)
│ │ ├── logs.py # GET /logs (in-memory buffer)
│ │ ├── prometheus.py # GET /prometheus-metrics
│ │ ├── dashboard.py # GET /dashboard (live HTML dashboard)
│ │ └── fail.py # GET /fail (chaos endpoint)
│ └── utils/
│ ├── alerts.py # Discord webhook health-check monitor
│ ├── events.py # Async event creation via ThreadPoolExecutor
│ ├── logger.py # JSONFormatter helper
│ └── ratelimit.py # Distributed token-bucket rate limiting (Redis Lua)
├── tests/ # 12 test files, ~1100+ lines
├── scripts/
│ ├── test_locust.py # Locust load test
│ ├── run_load_tests.sh # Automated sweep runner
│ ├── generate_traffic.py # Grafana traffic generator
│ └── load_csv.py # CSV seed data loader
├── terraform/ # AWS load testing infrastructure
│ ├── main.tf # AWS provider, tags
│ ├── variables.tf # Region, project name, DB password, task count
│ ├── vpc.tf # VPC, public/private subnets, IGW
│ ├── security_groups.tf # ALB, ECS, DB, Redis, loadtester SGs
│ ├── ecr.tf # ECR repository
│ ├── rds.tf # PostgreSQL db.t4g.medium
│ ├── elasticache.tf # Redis cache.t4g.medium
│ ├── alb.tf # ALB + target group + listener
│ ├── ecs.tf # ECS cluster, Fargate Spot, IAM, CloudWatch
│ ├── loadtester.tf # c6i.xlarge Spot instance
│ ├── outputs.tf # ALB DNS, loadtester IP, ECR URL
│ └── scripts/
│ ├── deploy.sh # Provision + ECR push + ECS deploy
│ └── run_loadtest.sh # SSH + headless Locust
├── loadtests/ # Load test results (CSVs, reports)
├── data/ # CSV seed data (users, urls, events)
├── nginx/nginx.conf # Reverse proxy to app:8000
├── prometheus/prometheus.yml # Scrapes app:8000/prometheus-metrics every 5s
├── grafana/ # Dashboards + provisioning
├── promtail/ # Docker log collection to Loki
├── docker-compose.yml # 7 services: app, postgres, redis, nginx, prometheus, grafana, loki+promtail
├── Dockerfile # Python 3.13-slim, uv, gunicorn (env-configurable workers)
├── run.py # Entry point: create_app() + app.run()
├── pyproject.toml # Package metadata (uv)
├── .env.example # Environment variable template
└── README.md
| Variable | Default | Description |
|---|---|---|
DATABASE_NAME |
hackathon_db |
PostgreSQL database name |
DATABASE_HOST |
localhost |
PostgreSQL host |
DATABASE_PORT |
5432 |
PostgreSQL port |
DATABASE_USER |
postgres |
PostgreSQL user |
DATABASE_PASSWORD |
postgres |
PostgreSQL password |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection URL for L2 cache |
FLASK_DEBUG |
false |
Enable Flask debug mode |
APP_URL |
http://127.0.0.1:5000 |
App URL for Discord health monitor |
DISCORD_WEBHOOK_URL |
— | Discord webhook for alerts |
LOG_LEVEL |
INFO |
Set to DEBUG for verbose logging |
GUNICORN_WORKERS |
4 |
Number of gunicorn worker processes |
GUNICORN_THREADS |
4 |
Threads per gunicorn worker |
# Run all tests
uv run pytest tests/
# Run with coverage
uv run pytest tests/ --cov=app --cov-report=term-missing
# Run specific test file
uv run pytest tests/test_cache_stampede.py