mirror of
https://github.com/vee1e/bulk-questionnaire-upload.git
synced 2026-09-01 09:50:06 +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: |
|
||||
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
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