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.
79 lines
2.6 KiB
Python
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()
|