feat: add CI perf-metrics job to benchmark deployed API and update README

- scripts/measure_perf.py — times validation, parsing, upload, delete
  against the live Render deployment; saves perf_results.json
- scripts/update_readme_metrics.py — reads results and rewrites the
  Performance Metrics Table in README.md with live numbers + timestamp
- ci.yml — new perf-metrics job (push to main only) runs after
  backend-tests pass, commits updated README back to the repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
vee1e 2026-06-02 13:49:30 +05:30
parent 252cb503fe
commit 962394155d
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
3 changed files with 317 additions and 0 deletions

View file

@ -30,6 +30,50 @@ jobs:
run: |
python -m pytest tests/backend -q
perf-metrics:
runs-on: ubuntu-latest
needs: backend-tests
# Only run on push to main not on PRs (no live deployment to hit)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
environment: Test
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install httpx pandas openpyxl
- name: Measure deployed performance
env:
BACKEND_URL: https://bulk-questionnaire-upload.onrender.com
run: python scripts/measure_perf.py
- name: Update README metrics table
run: python scripts/update_readme_metrics.py
- name: Commit updated README
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
if git diff --cached --quiet; then
echo "No changes to README metrics."
else
git commit -m "chore: update performance metrics from CI [skip ci]"
git push
fi
frontend-tests:
runs-on: ubuntu-latest
defaults:

165
scripts/measure_perf.py Normal file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Measure API performance against the live Render deployment.
Outputs perf_results.json in the repo root.
"""
import json
import os
import sys
import time
import httpx
import pandas as pd
BASE_URL = os.getenv("BACKEND_URL", "https://bulk-questionnaire-upload.onrender.com")
TEST_FILE = os.path.join(os.path.dirname(__file__), "../tests/test_xlsforms_valid/valid_form_1.xlsx")
RUNS = 3
def timed_runs(fn, runs=RUNS):
times = []
last_result = None
for _ in range(runs):
t0 = time.perf_counter()
last_result = fn()
times.append((time.perf_counter() - t0) * 1000)
return {
"min_ms": round(min(times), 1),
"max_ms": round(max(times), 1),
"avg_ms": round(sum(times) / len(times), 1),
}, last_result
def log(msg):
print(msg, flush=True)
def main():
results = {}
# ── Warmup (wake Render free tier if sleeping) ──────────────────────────
log("Warming up server (may take up to 60s on free tier)...")
for attempt in range(3):
try:
r = httpx.get(f"{BASE_URL}/api/forms", timeout=90)
log(f" Warmup response: {r.status_code}")
break
except Exception as e:
log(f" Warmup attempt {attempt + 1} failed: {e}")
if attempt == 2:
log("Server unreachable aborting perf run.")
sys.exit(1)
time.sleep(10)
# ── Cold-start latency (first real request after warmup) ─────────────────
t0 = time.perf_counter()
httpx.get(f"{BASE_URL}/api/forms", timeout=30)
results["cold_start_ms"] = round((time.perf_counter() - t0) * 1000, 1)
log(f"Cold-start: {results['cold_start_ms']}ms")
# ── File Validation ───────────────────────────────────────────────────────
def do_validate():
with open(TEST_FILE, "rb") as f:
return httpx.post(
f"{BASE_URL}/api/validate",
files={"file": ("valid_form_1.xlsx", f,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
timeout=30,
)
try:
m, _ = timed_runs(do_validate)
results["validation"] = m
log(f"Validation: {m}")
except Exception as e:
log(f"Validation failed: {e}")
results["validation"] = None
# ── Form Parsing ──────────────────────────────────────────────────────────
def do_parse():
with open(TEST_FILE, "rb") as f:
return httpx.post(
f"{BASE_URL}/api/forms/parse",
files={"file": ("valid_form_1.xlsx", f,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
timeout=60,
)
try:
m, _ = timed_runs(do_parse)
results["parsing"] = m
log(f"Parsing: {m}")
except Exception as e:
log(f"Parsing failed: {e}")
results["parsing"] = None
# ── Form Upload (requires MongoDB) ────────────────────────────────────────
uploaded_id = None
def do_upload():
with open(TEST_FILE, "rb") as f:
return httpx.post(
f"{BASE_URL}/api/upload",
files=[("files", ("valid_form_1.xlsx", f,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
timeout=90,
)
try:
m, resp = timed_runs(do_upload, runs=1)
if resp.status_code == 200:
results["upload"] = m
log(f"Upload: {m}")
data = resp.json()
for item in (data if isinstance(data, list) else []):
form = item.get("form") if isinstance(item, dict) else None
if form:
uploaded_id = form.get("id") or form.get("_id")
break
else:
log(f"Upload returned {resp.status_code} skipping (MongoDB not connected?)")
results["upload"] = None
except Exception as e:
log(f"Upload skipped: {e}")
results["upload"] = None
# ── Per-question / per-option (derived from upload) ───────────────────────
df = pd.read_excel(TEST_FILE, sheet_name=None)
question_count = len(df.get("Questions Info", pd.DataFrame()))
option_count = len(df.get("Answer Options", pd.DataFrame()))
if results.get("upload") and question_count:
avg = results["upload"]["avg_ms"]
results["per_question_ms"] = round(avg / question_count, 3)
results["per_option_ms"] = round(avg / option_count, 3) if option_count else None
else:
results["per_question_ms"] = None
results["per_option_ms"] = None
# ── Delete (requires MongoDB) ─────────────────────────────────────────────
if uploaded_id:
def do_delete():
return httpx.delete(f"{BASE_URL}/api/forms/{uploaded_id}", timeout=30)
try:
m, _ = timed_runs(do_delete, runs=1)
results["delete"] = m
log(f"Delete: {m}")
except Exception as e:
log(f"Delete failed: {e}")
results["delete"] = None
else:
log("Delete skipped (no uploaded form to delete)")
results["delete"] = None
# ── Save results ──────────────────────────────────────────────────────────
out_path = os.path.join(os.path.dirname(__file__), "../perf_results.json")
with open(out_path, "w") as f:
json.dump(results, f, indent=2)
log(f"\nResults saved to perf_results.json")
log(json.dumps(results, indent=2))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
Read perf_results.json and update the Performance Metrics Table in README.md.
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
ROOT = os.path.join(os.path.dirname(__file__), "..")
RESULTS_FILE = os.path.join(ROOT, "perf_results.json")
README_FILE = os.path.join(ROOT, "README.md")
def fmt_range(m):
"""Format a min/max/avg dict into a readable string."""
if m is None:
return "N/A (MongoDB not connected)"
lo, hi = m["min_ms"], m["max_ms"]
return f"{lo:.0f}{hi:.0f}ms ({lo/1000:.3f}{hi/1000:.3f}s)"
def fmt_per(ms):
if ms is None:
return "N/A (MongoDB not connected)"
return f"{ms:.3f}ms per item"
def build_table(r, updated_at):
v = r.get("validation")
p = r.get("parsing")
u = r.get("upload")
d = r.get("delete")
pq = r.get("per_question_ms")
po = r.get("per_option_ms")
cs = r.get("cold_start_ms")
cold_str = f"{cs:.0f}ms" if cs is not None else "N/A"
rows = [
("**File Validation**", fmt_range(v)),
("**Form Parsing**", fmt_range(p)),
("**Form Upload**", fmt_range(u)),
("**Question Processing**", fmt_per(pq)),
("**Option Processing**", fmt_per(po)),
("**Batch Processing**", fmt_range(u)),
("**Delete Operations**", fmt_range(d)),
("**Cold Start Time**", cold_str),
]
lines = [
f"<!-- PERF_TABLE_START -->",
f"",
f"*Last measured: {updated_at} UTC against the live Render deployment.*",
f"",
f"| Metric Type | Recent Performance |",
f"|-------------|-------------------|",
]
for label, value in rows:
lines.append(f"| {label} | {value} |")
lines.append("")
lines.append("<!-- PERF_TABLE_END -->")
return "\n".join(lines)
def main():
if not os.path.exists(RESULTS_FILE):
print("perf_results.json not found nothing to update.")
sys.exit(1)
with open(RESULTS_FILE) as f:
results = json.load(f)
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
new_table = build_table(results, updated_at)
with open(README_FILE) as f:
content = f.read()
# Replace between markers if they exist, otherwise replace the whole table
marker_pattern = re.compile(
r"<!-- PERF_TABLE_START -->.*?<!-- PERF_TABLE_END -->",
re.DOTALL,
)
if marker_pattern.search(content):
updated = marker_pattern.sub(new_table, content)
else:
# First run: inject markers around the existing table
table_pattern = re.compile(
r"(\| Metric Type \| Recent Performance \|.*?\n(?:\|.*?\n)+)",
re.DOTALL,
)
if table_pattern.search(content):
updated = table_pattern.sub(new_table + "\n", content)
else:
print("Could not find metrics table in README aborting.")
sys.exit(1)
with open(README_FILE, "w") as f:
f.write(updated)
print(f"README updated with metrics from {updated_at}")
if __name__ == "__main__":
main()