mirror of
https://github.com/vee1e/bulk-questionnaire-upload.git
synced 2026-09-01 17:57:10 +00:00
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:
parent
252cb503fe
commit
962394155d
3 changed files with 317 additions and 0 deletions
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
|
|
@ -30,6 +30,50 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
python -m pytest tests/backend -q
|
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:
|
frontend-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
||||||
165
scripts/measure_perf.py
Normal file
165
scripts/measure_perf.py
Normal 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()
|
||||||
108
scripts/update_readme_metrics.py
Normal file
108
scripts/update_readme_metrics.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue