155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
|
|
"""
|
||
|
|
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
|