vitals/browsers.py

155 lines
5.6 KiB
Python
Raw Normal View History

"""
Browser layer for Vitals — per-TAB accounting, not per-PID.
A browser is the top layer of the operating system: it is where the work
actually happens. Chrome-family browsers speak CDP when started with
--remote-debugging-port, and that gives us the two halves of the answer:
Performance.getMetrics per-tab TaskDuration / ScriptDuration / heap / nodes
SystemInfo.getProcessInfo per-process CPU (ids are real PIDs)
Join them and you can say: "this tab is idle, its GPU process is rasterizing
in a loop" — which is the signature of a browser eating a machine while
looking innocent.
"""
import time
import cdp
KNOWN_PORTS = [9222] # Brave/Chrome launched with the flag
SCAN_RANGE = range(9500, 9600) # headless automation instances
class TabConn:
def __init__(self, port):
self.port = port
self.ws = None
self.sessions = {} # target_id -> sessionId
self.prev = {} # target_id -> {metric: value}
self.prev_procs = {} # pid -> cpuTime
self.last_ok = 0.0
self.error = ""
def close(self):
if self.ws:
self.ws.close()
self.ws = None
self.sessions.clear()
def connect(self):
self.ws = cdp.WS(cdp.browser_ws(self.port), timeout=2.0)
self.sessions.clear()
self.prev.clear()
self.prev_procs.clear()
def poll(self, dt):
"""Return dict with tabs and per-process cpu for this browser."""
if not self.ws:
self.connect()
targets = [t for t in cdp.targets(self.port) if t.get("type") == "page"]
live = {t["id"] for t in targets}
for tid in list(self.sessions):
if tid not in live:
self.sessions.pop(tid, None)
self.prev.pop(tid, None)
tabs = []
for t in targets:
tid = t["id"]
try:
if tid not in self.sessions:
self.sessions[tid] = self.ws.call(
"Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
self.ws.call("Performance.enable", {}, self.sessions[tid])
s = self.sessions[tid]
m = {x["name"]: x["value"]
for x in self.ws.call("Performance.getMetrics", {}, s)["metrics"]}
except (RuntimeError, OSError, KeyError):
self.sessions.pop(tid, None)
continue
p = self.prev.get(tid)
self.prev[tid] = m
delta = {k: (m.get(k, 0) - p.get(k, 0)) if p else 0 for k in
("TaskDuration", "ScriptDuration", "LayoutDuration")}
tabs.append({
"id": tid,
"title": (t.get("title") or "").strip() or "(untitled)",
"url": t.get("url") or "",
"task_pct": round(delta["TaskDuration"] / dt * 100, 1) if dt else 0,
"script_share": round(delta["ScriptDuration"] / delta["TaskDuration"] * 100)
if delta["TaskDuration"] > 0.0005 else 0,
"heap_mb": round(m.get("JSHeapUsedSize", 0) / 1048576, 1),
"nodes": int(m.get("Nodes", 0)),
})
procs = {}
try:
for p in self.ws.call("SystemInfo.getProcessInfo")["processInfo"]:
pid, cpu = p.get("id"), p.get("cpuTime", 0.0)
prev = self.prev_procs.get(pid)
self.prev_procs[pid] = cpu
procs[pid] = {
"type": p.get("type", ""),
"cpu": round((cpu - prev) / dt * 100, 1) if prev is not None and dt else 0.0,
"cpu_total_s": round(cpu, 1),
}
except (RuntimeError, OSError, KeyError):
pass
self.last_ok = time.time()
return {"port": self.port, "tabs": tabs, "procs": procs}
class BrowserWatcher:
"""Polls every CDP-speaking browser on the box."""
def __init__(self, extra_ports=()):
self.conns = {}
self.last = {}
self.ports = list(KNOWN_PORTS) + list(extra_ports)
self._scan = []
self._scan_ts = 0.0
def discover(self):
"""Find live CDP endpoints: configured ports + a bounded scan (cached)."""
if time.time() - self._scan_ts < 20:
return list(self._scan)
found = []
for port in self.ports:
try:
cdp.http_json(f"http://127.0.0.1:{port}/json/version", timeout=0.4)
found.append(port)
except Exception:
pass
for port in SCAN_RANGE:
try:
cdp.http_json(f"http://127.0.0.1:{port}/json/version", timeout=0.12)
found.append(port)
except Exception:
pass
self._scan, self._scan_ts = found, time.time()
return list(found)
def poll(self, dt, ports=None):
ports = ports if ports is not None else self.discover()
out = {}
for port in ports:
conn = self.conns.get(port)
if not conn:
conn = self.conns[port] = TabConn(port)
try:
out[port] = conn.poll(dt)
conn.error = ""
except Exception as exc:
conn.error = str(exc)[:120]
conn.close()
self.conns.pop(port, None)
out[port] = {"port": port, "tabs": [], "procs": {}, "error": conn.error}
for port in list(self.conns):
if port not in ports:
self.conns[port].close()
self.conns.pop(port, None)
self.last = out
return out