From d2db31df06d092e00106a89c4ba9a54e117562a3 Mon Sep 17 00:00:00 2001 From: Paul Gabriel Date: Mon, 13 Oct 2025 18:19:15 +0200 Subject: [PATCH] Add load testing suite --- load-testing/.gitignore | 17 ++ load-testing/Makefile | 23 ++ load-testing/README.md | 259 ++++++++++++++++++ load-testing/docker-compose.yml | 40 +++ .../provisioning/dashboards/dashboard.yml | 12 + .../provisioning/datasources/influxdb.yml | 10 + load-testing/scenarios/donation-journey.js | 59 ++++ load-testing/tests/load.js | 21 ++ load-testing/tests/main.js | 57 ++++ load-testing/tests/smoke.js | 21 ++ load-testing/tests/spike.js | 23 ++ load-testing/tests/stress.js | 23 ++ load-testing/utils/config.js | 20 ++ load-testing/utils/helpers.js | 35 +++ 14 files changed, 620 insertions(+) create mode 100644 load-testing/.gitignore create mode 100644 load-testing/Makefile create mode 100644 load-testing/README.md create mode 100644 load-testing/docker-compose.yml create mode 100644 load-testing/grafana/provisioning/dashboards/dashboard.yml create mode 100644 load-testing/grafana/provisioning/datasources/influxdb.yml create mode 100644 load-testing/scenarios/donation-journey.js create mode 100644 load-testing/tests/load.js create mode 100644 load-testing/tests/main.js create mode 100644 load-testing/tests/smoke.js create mode 100644 load-testing/tests/spike.js create mode 100644 load-testing/tests/stress.js create mode 100644 load-testing/utils/config.js create mode 100644 load-testing/utils/helpers.js diff --git a/load-testing/.gitignore b/load-testing/.gitignore new file mode 100644 index 00000000..dd40f88a --- /dev/null +++ b/load-testing/.gitignore @@ -0,0 +1,17 @@ +# Dependencies +node_modules/ + +# Test results +*.json +*.csv +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo diff --git a/load-testing/Makefile b/load-testing/Makefile new file mode 100644 index 00000000..eb727250 --- /dev/null +++ b/load-testing/Makefile @@ -0,0 +1,23 @@ +.PHONY: help test test-with-monitoring up down clean shell + +help: ## Show this help message + @echo "Available commands:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + +test: ## Run stress-spike test (up to 8000 users, 8 min) + docker compose run --rm k6 run tests/main.js + +test-with-monitoring: ## Run test with InfluxDB monitoring + docker compose run --rm -e K6_OUT=influxdb=http://influxdb:8086 k6 run tests/main.js + +up: ## Start InfluxDB and Grafana services + docker compose up -d influxdb grafana + +down: ## Stop all services + docker compose down + +clean: ## Stop services and remove volumes + docker compose down -v + +shell: ## Open shell in k6 container + docker compose run --rm k6 sh diff --git a/load-testing/README.md b/load-testing/README.md new file mode 100644 index 00000000..4a12be0a --- /dev/null +++ b/load-testing/README.md @@ -0,0 +1,259 @@ +# Load Testing Suite for dataforgood.fr + +A high-intensity load testing suite for the Data for Good website using [Grafana k6](https://k6.io/). + +## Overview + +This suite tests the performance and scalability of the dataforgood.fr website under intense, realistic traffic conditions. The test simulates thousands of users spending approximately 1 minute on the site, with scenarios including stress testing and traffic spikes. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose +- (Optional) [Make](https://www.gnu.org/software/make/) for convenient command shortcuts + +## Quick Start + +### Using Make (Recommended) + +```bash +# Show all available commands +make help + +# Run the stress-spike test +make test + +# Run test with monitoring (stores results in InfluxDB) +make test-with-monitoring + +# Start monitoring stack (InfluxDB + Grafana) +make up + +# Stop all services +make down +``` + +### Using Docker Compose + +```bash +# Run the test +docker compose run --rm k6 run tests/main.js + +# Run with monitoring enabled +docker compose run --rm -e K6_OUT=influxdb=http://influxdb:8086 k6 run tests/main.js + +# Start monitoring stack +docker compose up -d influxdb grafana + +# Access Grafana at http://localhost:3000 +``` + +## Project Structure + +``` +load-testing/ +├── tests/ +│ └── main.js # Stress-spike test (up to 8000 users) +├── scenarios/ +│ └── donation-journey.js # Main page + donation page navigation (~1 min) +├── utils/ +│ ├── config.js # Configuration and thresholds +│ └── helpers.js # Helper functions +├── grafana/ +│ └── provisioning/ # Auto-configured datasources and dashboards +├── docker-compose.yml # Docker services configuration +├── Makefile # Convenient command shortcuts +├── package.json # npm scripts +└── README.md # This file +``` + +## The Test + +### Stress-Spike Test (tests/main.js) + +A comprehensive test combining stress testing and traffic spikes to simulate real-world high-traffic scenarios. + +**Profile:** +- **Duration:** 8 minutes +- **Peak Users:** 8000 concurrent users +- **User Behaviors:** + - 70% complete donation journey (main page → donation page, ~1 min) + - 30% quick browsing (main page only, ~30-45 sec) + +**Traffic Pattern:** +1. **Baseline (1 min):** Ramp to 1000 users +2. **Stress (1 min):** Increase to 3000 users +3. **Spike (30 sec):** Sudden jump to 8000 users +4. **Sustained (2 min):** Hold at 8000 users +5. **Recovery (1 min):** Drop to 3000 users +6. **Baseline (30 sec):** Return to 1000 users +7. **Ramp down (1 min):** Complete shutdown + +**Run the test:** +```bash +make test +# or with monitoring +make test-with-monitoring +``` + +## User Scenarios + +### Donation Journey (~1 minute) +1. Load the main page (https://dataforgood.fr/) +2. Scroll/read content (20-30 seconds) +3. Navigate to donation page (https://dataforgood.fr/faire-un-don) +4. Read donation page (20-30 seconds) + +### Quick Page Load (~30-45 seconds) +- Load the main page only +- Quick browsing (30-45 seconds) + +## Performance Thresholds + +The test uses spike test thresholds to account for extreme load: + +- 95th percentile response time < 6000ms (6 seconds) +- 99th percentile response time < 10000ms (10 seconds) +- Error rate < 15% + +These thresholds are intentionally relaxed to handle the extreme 8000-user spike while still catching major performance degradation. + +## Monitoring with Grafana + +The suite includes InfluxDB and Grafana services for real-time monitoring. + +### Start Monitoring Stack + +```bash +make up +# or +docker compose up -d influxdb grafana +``` + +### Access Grafana + +1. Open http://localhost:3000 in your browser +2. Login with default credentials (anonymous auth enabled) +3. The InfluxDB datasource is pre-configured + +### Run Tests with InfluxDB Output + +```bash +make test-with-monitoring +# or +docker compose run --rm -e K6_OUT=influxdb=http://influxdb:8086 k6 run tests/main.js +``` + +## Advanced Usage + +### Custom Test Parameters + +```bash +# Run with different target URL +docker compose run --rm k6 run tests/main.js -e BASE_URL=https://staging.dataforgood.fr +``` + +### Output Formats + +```bash +# JSON output +docker compose run --rm k6 run tests/main.js --out json=results.json + +# CSV output +docker compose run --rm k6 run tests/main.js --out csv=results.csv + +# Multiple outputs +docker compose run --rm k6 run tests/main.js --out json=results.json --out influxdb=http://influxdb:8086 +``` + +### Interactive Shell + +```bash +make shell +# or +docker compose run --rm k6 sh +``` + +## Interpreting Results + +k6 outputs comprehensive metrics: + +- **http_req_duration:** Request latency (p95, p99, median) +- **http_req_failed:** Percentage of failed requests +- **http_reqs:** Total HTTP requests per second +- **vus:** Number of active virtual users +- **iterations:** Number of scenario completions + +Example output: +``` + ✓ main page loaded successfully + ✓ donation page loaded successfully + + checks.........................: 100.00% ✓ 2340 ✗ 0 + data_received..................: 45 MB 750 kB/s + data_sent......................: 120 kB 2 kB/s + http_req_duration..............: avg=234ms min=120ms med=198ms max=890ms p(95)=450ms p(99)=650ms + http_reqs......................: 2340 39/s + iteration_duration.............: avg=8.2s min=7.1s med=8.1s max=15.8s p(95)=10.2s p(99)=12.5s + iterations.....................: 1170 19.5/s + vus............................: 50 min=0 max=50 + vus_max........................: 50 min=50 max=50 +``` + +## Tips for Success + +1. **Start with monitoring:** Always run `make up` first to start Grafana/InfluxDB +2. **Watch resources:** Monitor server CPU, memory, network, and database during the 8000-user spike +3. **Establish baselines:** Run tests regularly to track performance trends +4. **CDN is critical:** With 8000 users, ensure static assets are properly cached +5. **Database scaling:** Connection pools must handle extreme concurrent load +6. **Gradual scaling:** Consider starting with lower user counts and adjusting the test stages + +## Troubleshooting + +### Docker Issues + +```bash +# Restart services +docker compose restart + +# Clean up and rebuild +docker compose down -v +docker compose pull +``` + +### High Error Rates + +- Check server logs for errors +- Verify the application is running +- Monitor network connectivity +- Check if CDN is working properly + +### Performance Issues + +- Monitor server resources (CPU, memory, disk I/O) +- Review database query performance +- Check CDN cache hit rates +- Analyze slow queries in application logs +- Monitor network latency + +## Cleanup + +```bash +# Stop all services +make down + +# Stop services and remove volumes +make clean + +# Or with docker compose +docker compose down -v +``` + +## References + +- [k6 Documentation](https://k6.io/docs/) +- [k6 with Docker](https://k6.io/docs/getting-started/running-k6/#docker) +- [Test Types Guide](https://k6.io/docs/test-types/introduction/) +- [k6 Metrics](https://k6.io/docs/using-k6/metrics/) +- [Thresholds](https://k6.io/docs/using-k6/thresholds/) +- [k6 + InfluxDB + Grafana](https://k6.io/docs/results-output/real-time/influxdb/) diff --git a/load-testing/docker-compose.yml b/load-testing/docker-compose.yml new file mode 100644 index 00000000..a853a518 --- /dev/null +++ b/load-testing/docker-compose.yml @@ -0,0 +1,40 @@ +services: + k6: + image: grafana/k6:latest + volumes: + - ./:/app + working_dir: /app + environment: + - K6_OUT=${K6_OUT:-influxdb=http://influxdb:8086} + command: run tests/main.js + depends_on: + - influxdb + + # Optional: InfluxDB for storing results + influxdb: + image: influxdb:1.8 + ports: + - "8086:8086" + environment: + - INFLUXDB_DB=k6 + - INFLUXDB_HTTP_AUTH_ENABLED=false + volumes: + - influxdb-data:/var/lib/influxdb + + # Optional: Grafana for visualizing results + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + depends_on: + - influxdb + +volumes: + influxdb-data: + grafana-data: diff --git a/load-testing/grafana/provisioning/dashboards/dashboard.yml b/load-testing/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..90a45fcb --- /dev/null +++ b/load-testing/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'k6 Load Testing' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/load-testing/grafana/provisioning/datasources/influxdb.yml b/load-testing/grafana/provisioning/datasources/influxdb.yml new file mode 100644 index 00000000..4e7c2102 --- /dev/null +++ b/load-testing/grafana/provisioning/datasources/influxdb.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: InfluxDB_k6 + type: influxdb + access: proxy + url: http://influxdb:8086 + database: k6 + isDefault: true + editable: false diff --git a/load-testing/scenarios/donation-journey.js b/load-testing/scenarios/donation-journey.js new file mode 100644 index 00000000..a540ba6d --- /dev/null +++ b/load-testing/scenarios/donation-journey.js @@ -0,0 +1,59 @@ +import http from 'k6/http'; +import { BASE_URL } from '../utils/config.js'; +import { checkResponse, thinkTime } from '../utils/helpers.js'; + +/** + * Main Donation Journey Scenario + * Simulates a user scrolling through the main page and navigating to the donation page + * Total duration: ~1 minute + */ +export function donationJourney() { + // Step 1: Load main page + const mainPageRes = http.get(`${BASE_URL}/`, { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + }); + + checkResponse(mainPageRes, 200, 'main page loaded successfully'); + + // Simulate scrolling/reading the main page (~20-30 seconds) + thinkTime(20, 30); + + // Step 2: Navigate to donation page + const donationPageRes = http.get(`${BASE_URL}/faire-un-don`, { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + 'Referer': `${BASE_URL}/`, + }, + }); + + checkResponse(donationPageRes, 200, 'donation page loaded successfully'); + + // Simulate reading the donation page (~20-30 seconds) + thinkTime(20, 30); +} + +/** + * Quick Page Load + * Simulates a user just loading and quickly browsing the main page + * Total duration: ~30-45 seconds + */ +export function quickPageLoad() { + const res = http.get(`${BASE_URL}/`, { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + }); + + checkResponse(res, 200, 'page load successful'); + + // Quick browsing (~30-45 seconds) + thinkTime(30, 45); +} diff --git a/load-testing/tests/load.js b/load-testing/tests/load.js new file mode 100644 index 00000000..b7f53e70 --- /dev/null +++ b/load-testing/tests/load.js @@ -0,0 +1,21 @@ +import { THRESHOLDS } from '../utils/config.js'; +import { donationJourney } from '../scenarios/donation-journey.js'; + +/** + * Load Test + * Purpose: Test system under sustained high load + * Duration: 5 minutes + * Users: Up to 2000 concurrent users + */ +export const options = { + stages: [ + { duration: '1m', target: 2000 }, + { duration: '3m', target: 2000 }, + { duration: '1m', target: 0 }, + ], + thresholds: THRESHOLDS.load, +}; + +export default function () { + donationJourney(); +} diff --git a/load-testing/tests/main.js b/load-testing/tests/main.js new file mode 100644 index 00000000..9078d52f --- /dev/null +++ b/load-testing/tests/main.js @@ -0,0 +1,57 @@ +import { THRESHOLDS } from '../utils/config.js'; +import { donationJourney, quickPageLoad } from '../scenarios/donation-journey.js'; + +/** + * Main Stress-Spike Test Suite + * Purpose: Test system under high load with traffic spikes + * Duration: 8 minutes + * Users: Up to 8000 concurrent users with realistic spikes + * + * Profile: + * - Baseline: 1000 users + * - Stress: Ramp to 3000 users + * - Spike: Sudden jump to 8000 users + * - Recovery: Back to baseline + */ +export const options = { + scenarios: { + // 70% users: Complete donation journey + donation_journey: { + executor: 'ramping-vus', + exec: 'completeDonationJourney', + startVUs: 0, + stages: [ + { duration: '1m', target: 700 }, // Baseline + { duration: '1m', target: 2100 }, // Stress test + { duration: '30s', target: 5600 }, // Spike! + { duration: '2m', target: 5600 }, // Sustained spike + { duration: '1m', target: 2100 }, // Recovery + { duration: '30s', target: 700 }, // Back to baseline + { duration: '1m', target: 0 }, // Ramp down + ], + }, + + // 30% users: Just browsing the main page + page_browsers: { + executor: 'ramping-vus', + exec: 'quickPageLoad', + startVUs: 0, + stages: [ + { duration: '1m', target: 300 }, // Baseline + { duration: '1m', target: 900 }, // Stress test + { duration: '30s', target: 2400 }, // Spike! + { duration: '2m', target: 2400 }, // Sustained spike + { duration: '1m', target: 900 }, // Recovery + { duration: '30s', target: 300 }, // Back to baseline + { duration: '1m', target: 0 }, // Ramp down + ], + }, + }, + thresholds: THRESHOLDS.spike, +}; + +export function completeDonationJourney() { + donationJourney(); +} + +export { quickPageLoad }; diff --git a/load-testing/tests/smoke.js b/load-testing/tests/smoke.js new file mode 100644 index 00000000..768bf964 --- /dev/null +++ b/load-testing/tests/smoke.js @@ -0,0 +1,21 @@ +import { THRESHOLDS } from '../utils/config.js'; +import { donationJourney } from '../scenarios/donation-journey.js'; + +/** + * Smoke Test + * Purpose: Verify the system works under baseline load + * Duration: 2 minutes + * Users: Up to 500 concurrent users + */ +export const options = { + stages: [ + { duration: '30s', target: 500 }, + { duration: '1m', target: 500 }, + { duration: '30s', target: 0 }, + ], + thresholds: THRESHOLDS.smoke, +}; + +export default function () { + donationJourney(); +} diff --git a/load-testing/tests/spike.js b/load-testing/tests/spike.js new file mode 100644 index 00000000..d8e5e268 --- /dev/null +++ b/load-testing/tests/spike.js @@ -0,0 +1,23 @@ +import { THRESHOLDS } from '../utils/config.js'; +import { donationJourney } from '../scenarios/donation-journey.js'; + +/** + * Spike Test + * Purpose: Test system under sudden spikes + * Duration: 10 minutes + * Users: Sudden spike from 10 to 100 users + */ +export const options = { + stages: [ + { duration: '1m', target: 10 }, + { duration: '30s', target: 100 }, // Sudden spike + { duration: '5m', target: 100 }, + { duration: '2m', target: 10 }, + { duration: '1m', target: 0 }, + ], + thresholds: THRESHOLDS.spike, +}; + +export default function () { + donationJourney(); +} diff --git a/load-testing/tests/stress.js b/load-testing/tests/stress.js new file mode 100644 index 00000000..0c1f86be --- /dev/null +++ b/load-testing/tests/stress.js @@ -0,0 +1,23 @@ +import { THRESHOLDS } from '../utils/config.js'; +import { donationJourney } from '../scenarios/donation-journey.js'; + +/** + * Stress Test + * Purpose: Find the breaking point + * Duration: 6 minutes + * Users: Up to 5000 concurrent users + */ +export const options = { + stages: [ + { duration: '1m', target: 2000 }, + { duration: '1m', target: 3500 }, + { duration: '2m', target: 5000 }, + { duration: '1m', target: 2000 }, + { duration: '1m', target: 0 }, + ], + thresholds: THRESHOLDS.stress, +}; + +export default function () { + donationJourney(); +} diff --git a/load-testing/utils/config.js b/load-testing/utils/config.js new file mode 100644 index 00000000..9c4ea53f --- /dev/null +++ b/load-testing/utils/config.js @@ -0,0 +1,20 @@ +export const BASE_URL = 'https://dataforgood.fr'; + +export const THRESHOLDS = { + smoke: { + http_req_duration: ['p(95)<3000', 'p(99)<5000'], + http_req_failed: ['rate<0.02'], + }, + load: { + http_req_duration: ['p(95)<4000', 'p(99)<6000'], + http_req_failed: ['rate<0.05'], + }, + stress: { + http_req_duration: ['p(95)<5000', 'p(99)<8000'], + http_req_failed: ['rate<0.10'], + }, + spike: { + http_req_duration: ['p(95)<6000', 'p(99)<10000'], + http_req_failed: ['rate<0.15'], + }, +}; diff --git a/load-testing/utils/helpers.js b/load-testing/utils/helpers.js new file mode 100644 index 00000000..988f09d2 --- /dev/null +++ b/load-testing/utils/helpers.js @@ -0,0 +1,35 @@ +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +export const errorRate = new Rate('errors'); + +/** + * Check HTTP response and track errors + */ +export function checkResponse(res, expectedStatus = 200, checkName = 'status is 200') { + const statusCheck = Array.isArray(expectedStatus) + ? expectedStatus.includes(res.status) + : res.status === expectedStatus; + + const result = check(res, { + [checkName]: () => statusCheck, + }); + + errorRate.add(!result); + + return result; +} + +/** + * Simulate realistic think time between actions + */ +export function thinkTime(min = 1, max = 3) { + sleep(Math.random() * (max - min) + min); +} + +/** + * Get random element from array + */ +export function randomItem(array) { + return array[Math.floor(Math.random() * array.length)]; +}