Files
shopdb-flask/scripts/loadtest/ui_probe.py
benjamin.baker 0d5b9730e3 Add load test harness for pool and fleet sizing
Adds the scripts used to size the connection pool and validate fleet polling under load: burst arrival and mixed workload drivers, pool and resource samplers, a UI probe, host seed/drop SQL, and a gevent Dockerfile. Hosts are addressed by compose service name (api:5001) rather than a local path, so the harness runs against any stack rather than one machine.

loadtest_token.py mints a scoped collector token named LOADTEST and prints the secret once, then revokes only that token by name so a real collector token is never touched. envkeyfallback is false, so the harness needs a genuine managed token rather than an env key.
2026-09-07 12:39:53 -04:00

79 lines
2.6 KiB
Python

"""Measure UI latency against the API, independent of any load generator.
WHY separate from poll_fleet.py: when the probe shares an event loop with the
simulated fleet, generator-side scheduling can starve the probe and produce UI
latencies that are an artifact of the harness rather than of the server. Run
this in its own container, concurrently with the fleet, so the two cannot
interfere.
Usage:
python ui_probe.py --seconds 90 --interval 1.0
"""
import argparse
import json
import ssl
import statistics
import time
import urllib.request
def percentile(values, fraction):
if not values:
return 0.0
ordered = sorted(values)
index = min(int(len(ordered) * fraction), len(ordered) - 1)
return ordered[index]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--seconds', type=int, default=90)
parser.add_argument('--interval', type=float, default=1.0)
parser.add_argument('--url',
default='https://api:5001/api/dashboard/health')
parser.add_argument('--timeout', type=float, default=120.0)
arguments = parser.parse_args()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
durations = []
failures = {}
deadline = time.monotonic() + arguments.seconds
while time.monotonic() < deadline:
started = time.monotonic()
try:
request = urllib.request.Request(arguments.url)
with urllib.request.urlopen(
request, timeout=arguments.timeout,
context=context) as response:
response.read()
if response.status == 200:
durations.append(time.monotonic() - started)
else:
key = str(response.status)
failures[key] = failures.get(key, 0) + 1
except Exception as error:
key = type(error).__name__
failures[key] = failures.get(key, 0) + 1
remaining = arguments.interval - (time.monotonic() - started)
if remaining > 0:
time.sleep(remaining)
print(json.dumps({
'ui_samples': len(durations) + sum(failures.values()),
'ui_ok_200': len(durations),
'ui_failures': failures,
'ui_median_ms': round(statistics.median(durations) * 1000, 1)
if durations else None,
'ui_p95_ms': round(percentile(durations, 0.95) * 1000, 1)
if durations else None,
'ui_max_ms': round(max(durations) * 1000, 1) if durations else None,
}, indent=2))
if __name__ == '__main__':
main()