mirror of
https://github.com/vee1e/bulk-questionnaire-upload.git
synced 2026-09-01 17:57:10 +00:00
- update_readme_metrics.py: fall back to existing README values instead of overwriting with "N/A (MongoDB not connected)" when metrics are unavailable - measure_perf.py: fix uploaded_id extraction — ParsedForm.id is top-level, not nested under a "form" key, so delete timing now actually runs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
#!/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 [data]):
|
||
if isinstance(item, dict):
|
||
uploaded_id = item.get("id") or item.get("_id")
|
||
if uploaded_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()
|