"""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()