|
| 1 | +""" |
| 2 | +Profile page load times for a sequence of URLs. |
| 3 | +Captures: navigation timing, all network requests, console errors. |
| 4 | +Specifically surfaces buckaroo-related JS timing via performance marks/measures. |
| 5 | +""" |
| 6 | + |
| 7 | +import time |
| 8 | + |
| 9 | +from playwright.sync_api import sync_playwright |
| 10 | + |
| 11 | +URLS = [ |
| 12 | + "http://localhost:7860/first-project/catalog/6fdd4dad1447", |
| 13 | + "http://localhost:7860/first-project/diff/route_distance/1/2", |
| 14 | + "http://localhost:7860/first-project/catalog/763193211746", |
| 15 | + "http://localhost:7860/first-project/catalog/0d0b75dfa627", |
| 16 | +] |
| 17 | + |
| 18 | +# How long to wait after DOMContentLoaded for async renders (buckaroo data fetch + render) |
| 19 | +SETTLE_MS = 8000 |
| 20 | + |
| 21 | + |
| 22 | +def fmt_ms(ms): |
| 23 | + if ms is None: |
| 24 | + return " N/A " |
| 25 | + return f"{ms:7.0f}ms" |
| 26 | + |
| 27 | + |
| 28 | +def profile_url(page, url, index): |
| 29 | + print(f"\n{'='*70}") |
| 30 | + print(f"[{index+1}/{len(URLS)}] {url}") |
| 31 | + print(f"{'='*70}") |
| 32 | + |
| 33 | + requests = [] |
| 34 | + console_errors = [] |
| 35 | + |
| 36 | + def on_request(req): |
| 37 | + requests.append({"url": req.url, "method": req.method, "start": time.monotonic()}) |
| 38 | + |
| 39 | + def on_response(resp): |
| 40 | + for r in reversed(requests): |
| 41 | + if r["url"] == resp.url and "end" not in r: |
| 42 | + r["end"] = time.monotonic() |
| 43 | + r["status"] = resp.status |
| 44 | + r["size"] = resp.headers.get("content-length", "?") |
| 45 | + break |
| 46 | + |
| 47 | + def on_console(msg): |
| 48 | + if msg.type == "error": |
| 49 | + console_errors.append(msg.text) |
| 50 | + |
| 51 | + page.on("request", on_request) |
| 52 | + page.on("response", on_response) |
| 53 | + page.on("console", on_console) |
| 54 | + |
| 55 | + t_nav_start = time.monotonic() |
| 56 | + page.goto(url, wait_until="domcontentloaded") |
| 57 | + t_dom = time.monotonic() |
| 58 | + |
| 59 | + # Wait for the page to settle (network idle or timeout) |
| 60 | + try: |
| 61 | + page.wait_for_load_state("networkidle", timeout=SETTLE_MS) |
| 62 | + except Exception: |
| 63 | + pass |
| 64 | + t_idle = time.monotonic() |
| 65 | + |
| 66 | + # Grab Navigation Timing from the browser |
| 67 | + nav_timing = page.evaluate("""() => { |
| 68 | + const e = performance.getEntriesByType('navigation')[0]; |
| 69 | + if (!e) return null; |
| 70 | + return { |
| 71 | + dns: e.domainLookupEnd - e.domainLookupStart, |
| 72 | + tcp: e.connectEnd - e.connectStart, |
| 73 | + ttfb: e.responseStart - e.requestStart, |
| 74 | + response_download: e.responseEnd - e.responseStart, |
| 75 | + dom_interactive: e.domInteractive, |
| 76 | + dom_complete: e.domComplete, |
| 77 | + load_event: e.loadEventEnd, |
| 78 | + }; |
| 79 | + }""") |
| 80 | + |
| 81 | + # Grab all resource entries (JS, API calls) |
| 82 | + resources = page.evaluate("""() => { |
| 83 | + return performance.getEntriesByType('resource').map(e => ({ |
| 84 | + name: e.name, |
| 85 | + type: e.initiatorType, |
| 86 | + duration: e.duration, |
| 87 | + transfer_size: e.transferSize, |
| 88 | + start: e.startTime, |
| 89 | + })); |
| 90 | + }""") |
| 91 | + |
| 92 | + # Any perf marks/measures (buckaroo may emit these) |
| 93 | + marks_measures = page.evaluate("""() => { |
| 94 | + const marks = performance.getEntriesByType('mark').map(e => ({kind:'mark', name:e.name, start:e.startTime})); |
| 95 | + const measures = performance.getEntriesByType('measure').map( |
| 96 | + e => ({kind:'measure', name:e.name, start:e.startTime, duration:e.duration})); |
| 97 | + return [...marks, ...measures].sort((a,b) => a.start - b.start); |
| 98 | + }""") |
| 99 | + |
| 100 | + # --- Print Navigation Timing --- |
| 101 | + if nav_timing: |
| 102 | + print("\n Navigation Timing:") |
| 103 | + print(f" DNS lookup: {fmt_ms(nav_timing['dns'])}") |
| 104 | + print(f" TCP connect: {fmt_ms(nav_timing['tcp'])}") |
| 105 | + print(f" TTFB: {fmt_ms(nav_timing['ttfb'])}") |
| 106 | + print(f" Response download: {fmt_ms(nav_timing['response_download'])}") |
| 107 | + print(f" DOM interactive: {fmt_ms(nav_timing['dom_interactive'])}") |
| 108 | + print(f" DOM complete: {fmt_ms(nav_timing['dom_complete'])}") |
| 109 | + print(f" Load event end: {fmt_ms(nav_timing['load_event'])}") |
| 110 | + |
| 111 | + wall_to_dom = (t_dom - t_nav_start) * 1000 |
| 112 | + wall_to_idle = (t_idle - t_nav_start) * 1000 |
| 113 | + print("\n Wall-clock:") |
| 114 | + print(f" To DOMContentLoaded: {fmt_ms(wall_to_dom)}") |
| 115 | + print(f" To network idle: {fmt_ms(wall_to_idle)}") |
| 116 | + |
| 117 | + # --- API requests --- |
| 118 | + api_reqs = [r for r in requests if "/api/" in r["url"] or r["url"].endswith(".json")] |
| 119 | + if api_reqs: |
| 120 | + print(f"\n API requests ({len(api_reqs)}):") |
| 121 | + for r in sorted(api_reqs, key=lambda x: x["start"]): |
| 122 | + dur = (r.get("end", time.monotonic()) - r["start"]) * 1000 |
| 123 | + path = r["url"].split("localhost:7860")[-1] |
| 124 | + status = r.get("status", "?") |
| 125 | + print(f" [{status}] {fmt_ms(dur)} {path}") |
| 126 | + |
| 127 | + # --- Slowest JS/resource loads --- |
| 128 | + js_resources = [r for r in resources if r["type"] in ("script", "fetch", "xmlhttprequest")] |
| 129 | + if js_resources: |
| 130 | + slow = sorted(js_resources, key=lambda x: -x["duration"])[:8] |
| 131 | + print("\n Slowest resources (script/fetch, top 8):") |
| 132 | + for r in slow: |
| 133 | + name = r["name"].split("localhost:7860")[-1] |
| 134 | + kb = f"{r['transfer_size']/1024:.1f}kB" if r["transfer_size"] else "cached" |
| 135 | + print(f" {fmt_ms(r['duration'])} [{kb:>10}] {name}") |
| 136 | + |
| 137 | + # --- Performance marks/measures (buckaroo + app) --- |
| 138 | + if marks_measures: |
| 139 | + print(f"\n Perf marks/measures ({len(marks_measures)}):") |
| 140 | + for m in marks_measures: |
| 141 | + if m["kind"] == "measure": |
| 142 | + print(f" MEASURE {fmt_ms(m['duration'])} @{m['start']:.0f}ms {m['name']}") |
| 143 | + else: |
| 144 | + print(f" mark @{m['start']:.0f}ms {m['name']}") |
| 145 | + else: |
| 146 | + print("\n No perf marks/measures found.") |
| 147 | + |
| 148 | + if console_errors: |
| 149 | + print(f"\n Console errors ({len(console_errors)}):") |
| 150 | + for e in console_errors[:5]: |
| 151 | + print(f" ERROR: {e[:120]}") |
| 152 | + |
| 153 | + page.remove_listener("request", on_request) |
| 154 | + page.remove_listener("response", on_response) |
| 155 | + page.remove_listener("console", on_console) |
| 156 | + |
| 157 | + return { |
| 158 | + "url": url, |
| 159 | + "wall_to_dom_ms": wall_to_dom, |
| 160 | + "wall_to_idle_ms": wall_to_idle, |
| 161 | + "api_count": len(api_reqs), |
| 162 | + } |
| 163 | + |
| 164 | + |
| 165 | +def main(): |
| 166 | + with sync_playwright() as p: |
| 167 | + browser = p.chromium.launch(headless=True) |
| 168 | + context = browser.new_context( |
| 169 | + # disable cache so we get real cold-load numbers per page |
| 170 | + # (but keep warm across the session so JS bundle only loads once) |
| 171 | + ) |
| 172 | + page = context.new_page() |
| 173 | + |
| 174 | + results = [] |
| 175 | + for i, url in enumerate(URLS): |
| 176 | + result = profile_url(page, url, i) |
| 177 | + results.append(result) |
| 178 | + time.sleep(0.5) |
| 179 | + |
| 180 | + print(f"\n{'='*70}") |
| 181 | + print("SUMMARY") |
| 182 | + print(f"{'='*70}") |
| 183 | + print(f" {'URL':<45} {'To DOM':>9} {'To Idle':>9} {'API#':>5}") |
| 184 | + print(f" {'-'*45} {'-'*9} {'-'*9} {'-'*5}") |
| 185 | + for r in results: |
| 186 | + path = r["url"].split("localhost:7860")[-1] |
| 187 | + print( |
| 188 | + f" {path:<45} {fmt_ms(r['wall_to_dom_ms']):>9} " |
| 189 | + f"{fmt_ms(r['wall_to_idle_ms']):>9} {r['api_count']:>5}" |
| 190 | + ) |
| 191 | + |
| 192 | + browser.close() |
| 193 | + |
| 194 | + |
| 195 | +if __name__ == "__main__": |
| 196 | + main() |
0 commit comments