parsek-cdp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ """parsek-cdp-server -- the server scope: launch, supervise and proxy a browser.
2
+
3
+ Separate distribution from ``parsek-cdp`` (the client); installed via
4
+ ``pip install parsek-cdp[server]``. It *depends on* ``parsek-cdp`` and reuses
5
+ its shared layers wholesale -- the generated ``cdp`` protocol, the ``core``
6
+ primitives (``CDPConnection``, ``Target``, ``DataType``) and the ``parsek``
7
+ contract -- adding only what must live next to the browser:
8
+
9
+ * :mod:`~parsek_cdp_server.launcher` -- spawn Chrome, find its websocket;
10
+ * :mod:`~parsek_cdp_server.supervisor` -- own the browser lifecycle: health,
11
+ crash detection, restart, idle shutdown, and ``Parsek.browserStateChanged``
12
+ broadcast;
13
+ * :mod:`~parsek_cdp_server.proxy` -- accept client websockets and bridge
14
+ each one 1:1 to a Chrome target (no ``sessionId`` multiplexing, no id remap),
15
+ hosting feature producers on page pipes and serving the ``Parsek.*`` surface;
16
+ * :mod:`~parsek_cdp_server.metrics` -- Prometheus exposition at ``/metrics``
17
+ (browsers, targets/types, nested targets, per-target CDP events, CPU/RAM);
18
+ * :mod:`~parsek_cdp_server.reaper` -- a separate subprocess that kills leaked
19
+ (zombie/orphaned) parsek-launched Chrome processes.
20
+
21
+ Feature *producers* (raw CDP -> ``Parsek.*``) are shared code shipped in the
22
+ client distribution (:mod:`parsek_cdp.features`); the server hosts them but does
23
+ not define them. ``ParsekServer`` registers none by default -- it is a pure
24
+ passthrough until features are added via ``register_feature``.
25
+
26
+ Endpoints (see :mod:`~parsek_cdp_server.proxy`)::
27
+
28
+ HTTP POST /browsers create task -> browser_uuid
29
+ HTTP GET /metrics Prometheus metrics
30
+ ws /cdp/{browser_uuid}/control lifecycle + Parsek.*
31
+ ws /cdp/{browser_uuid}/page/{target_id} CDP proxy (per-target) + Parsek.*
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from .launcher import ChromeLauncher
37
+ from .metrics import ServerMetrics
38
+ from .proxy import ParsekServer
39
+ from .supervisor import BrowserSupervisor
40
+
41
+ __all__ = [
42
+ "ParsekServer",
43
+ "ChromeLauncher",
44
+ "BrowserSupervisor",
45
+ "ServerMetrics",
46
+ ]
@@ -0,0 +1,303 @@
1
+ """Launching a browser process and locating its DevTools websocket.
2
+
3
+ The launcher is the lowest server layer: it knows how to start Chrome/Chromium
4
+ with the right flags, wait until its DevTools endpoint is up, and read the
5
+ browser-level websocket url from ``/json/version``. Lifecycle decisions
6
+ (restart on crash, etc.) belong to :class:`~parsek_cdp_server.supervisor`, which
7
+ drives this class.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import random
16
+ import shutil
17
+ import signal
18
+ import tempfile
19
+ import urllib.error
20
+ import urllib.request
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import List, Optional
24
+
25
+ import psutil
26
+
27
+ #: Env var naming *directories* to search for browser binaries,
28
+ #: ``os.pathsep``-separated (like ``PATH``), e.g. ``/opt/browsers/chrome:/opt/browsers/brave``.
29
+ #: Each directory is walked recursively; every executable file whose name is a
30
+ #: known Chromium browser (see :data:`_BROWSER_NAMES`) becomes a candidate, and
31
+ #: each launch runs a randomly chosen one -- a cheap way to spread load (and
32
+ #: fingerprints) across several installed browsers. When unset,
33
+ #: :data:`_KNOWN_PATHS` is searched as the default.
34
+ CHROMES_PATH_ENV = "PARSEK_CHROMES_PATH"
35
+
36
+ #: Prefix of the temp profile directories the launcher creates (``tempfile``
37
+ #: prefix). It doubles as an ownership *marker*: the reaper recognises a browser
38
+ #: as parsek-launched by finding ``--user-data-dir=<...PROFILE_PREFIX...>`` in its
39
+ #: argv, so it only ever kills processes we started.
40
+ PROFILE_PREFIX = "parsek-cdp-"
41
+
42
+ #: Recognised Chromium-browser executable names (matched case-insensitively, with
43
+ #: any ``.exe`` suffix stripped). Restricting the recursive search to these
44
+ #: avoids picking up helper binaries (``chrome_crashpad_handler``, ``chrome_sandbox``).
45
+ _BROWSER_NAMES = frozenset(
46
+ {
47
+ "chrome",
48
+ "google-chrome",
49
+ "google-chrome-stable",
50
+ "chromium",
51
+ "chromium-browser",
52
+ "msedge",
53
+ "microsoft-edge",
54
+ "microsoft-edge-stable",
55
+ "brave",
56
+ "brave-browser",
57
+ "vivaldi",
58
+ "vivaldi-bin",
59
+ "opera",
60
+ }
61
+ )
62
+
63
+ #: Default directories searched when :data:`CHROMES_PATH_ENV` is unset. Any
64
+ #: Chromium-based browser works -- they all speak the same DevTools Protocol.
65
+ #: Non-existent dirs are skipped, so listing every platform here is harmless.
66
+ _KNOWN_PATHS = (
67
+ # Linux
68
+ "/usr/bin",
69
+ "/usr/local/bin",
70
+ "/opt",
71
+ "/snap/bin",
72
+ # macOS
73
+ "/Applications/Google Chrome.app/Contents/MacOS",
74
+ "/Applications/Chromium.app/Contents/MacOS",
75
+ "/Applications/Microsoft Edge.app/Contents/MacOS",
76
+ "/Applications/Brave Browser.app/Contents/MacOS",
77
+ # Windows
78
+ r"C:\Program Files\Google\Chrome\Application",
79
+ r"C:\Program Files (x86)\Google\Chrome\Application",
80
+ r"C:\Program Files (x86)\Microsoft\Edge\Application",
81
+ r"C:\Program Files\BraveSoftware\Brave-Browser\Application",
82
+ )
83
+
84
+ #: Executable names tried on ``PATH`` as a last resort when neither
85
+ #: :attr:`LaunchOptions.executable` nor a directory search yields a binary.
86
+ _CANDIDATES = (
87
+ "google-chrome",
88
+ "google-chrome-stable",
89
+ "chromium",
90
+ "chromium-browser",
91
+ "microsoft-edge",
92
+ "microsoft-edge-stable",
93
+ "brave-browser",
94
+ "brave",
95
+ "chrome",
96
+ )
97
+
98
+
99
+ def _is_browser(filename: str) -> bool:
100
+ name = filename[:-4] if filename.lower().endswith(".exe") else filename
101
+ return name.lower() in _BROWSER_NAMES
102
+
103
+
104
+ def _find_in_dirs(dirs: List[str]) -> List[str]:
105
+ """Recursively collect browser executables found under ``dirs``."""
106
+ found: List[str] = []
107
+ for directory in dirs:
108
+ directory = directory.strip()
109
+ if not directory or not os.path.isdir(directory):
110
+ continue
111
+ for root, _subdirs, files in os.walk(directory):
112
+ for filename in files:
113
+ if not _is_browser(filename):
114
+ continue
115
+ full = os.path.join(root, filename)
116
+ if os.path.isfile(full) and os.access(full, os.X_OK):
117
+ found.append(full)
118
+ return found
119
+
120
+
121
+ def _detect_executable() -> str:
122
+ """Pick a Chromium-based binary, or raise if none is available.
123
+
124
+ Precedence: a random browser executable found by recursively searching the
125
+ directories in :data:`CHROMES_PATH_ENV` (defaulting to :data:`_KNOWN_PATHS`),
126
+ else the first :data:`_CANDIDATES` name resolvable on ``PATH``.
127
+ """
128
+ raw = os.environ.get(CHROMES_PATH_ENV)
129
+ search_dirs = raw.split(os.pathsep) if raw else list(_KNOWN_PATHS)
130
+ matches = _find_in_dirs(search_dirs)
131
+ if matches:
132
+ return random.choice(matches)
133
+ for name in _CANDIDATES:
134
+ resolved = shutil.which(name)
135
+ if resolved:
136
+ return resolved
137
+ raise FileNotFoundError(
138
+ f"no Chrome/Chromium executable found; set {CHROMES_PATH_ENV} "
139
+ "(os.pathsep-separated directories to search) or LaunchOptions.executable"
140
+ )
141
+
142
+
143
+ @dataclass
144
+ class LaunchOptions:
145
+ """How to start the browser."""
146
+
147
+ executable: Optional[str] = None # autodetect if None
148
+ headless: bool = True
149
+ port: int = 0
150
+ user_data_dir: Optional[str] = None
151
+ extra_args: List[str] = field(default_factory=list)
152
+ default_args: bool = True
153
+
154
+
155
+ class ChromeLauncher:
156
+ """Spawn a browser process and expose its browser-level websocket url.
157
+
158
+ Responsibilities:
159
+
160
+ * build the argv (``--remote-debugging-port``, ``--user-data-dir``,
161
+ ``--headless=new``, ``--no-first-run``, ...);
162
+ * start the subprocess; with ``port == 0`` Chrome writes the port it chose to
163
+ the ``DevToolsActivePort`` file in the profile dir, which we poll;
164
+ * poll ``/json/version`` until ``webSocketDebuggerUrl`` is available;
165
+ * expose :attr:`pid` and :attr:`ws_url`; provide :meth:`terminate`.
166
+ """
167
+
168
+ def __init__(self, options: Optional[LaunchOptions] = None) -> None:
169
+ self.options = options or LaunchOptions()
170
+ self.pid: Optional[int] = None
171
+ self.ws_url: Optional[str] = None
172
+ self._proc: Optional[asyncio.subprocess.Process] = None
173
+ #: Temp profile dir we created (and must clean up); None if caller supplied one.
174
+ self._owned_user_data_dir: Optional[str] = None
175
+
176
+ # -- argv -------------------------------------------------------------- #
177
+
178
+ def _resolve_user_data_dir(self) -> str:
179
+ if self.options.user_data_dir is not None:
180
+ return self.options.user_data_dir
181
+ path = tempfile.mkdtemp(prefix=PROFILE_PREFIX)
182
+ self._owned_user_data_dir = path
183
+ return path
184
+
185
+ def _build_argv(self, user_data_dir: str) -> List[str]:
186
+ executable = self.options.executable or _detect_executable()
187
+ argv = [executable, f"--remote-debugging-port={self.options.port}"]
188
+ if self.options.default_args:
189
+ argv += [
190
+ f"--user-data-dir={user_data_dir}",
191
+ "--no-first-run",
192
+ "--no-default-browser-check",
193
+ "--disable-background-networking",
194
+ "--disable-popup-blocking",
195
+ "--remote-allow-origins=*",
196
+ ]
197
+ if self.options.headless:
198
+ argv += ["--headless=new", "--disable-gpu"]
199
+ argv += self.options.extra_args
200
+ return argv
201
+
202
+ async def launch(self) -> str:
203
+ """Start the browser and return its browser-level websocket url."""
204
+ user_data_dir = self._resolve_user_data_dir()
205
+ argv = self._build_argv(user_data_dir)
206
+ self._proc = await asyncio.create_subprocess_exec(
207
+ *argv,
208
+ stdout=asyncio.subprocess.DEVNULL,
209
+ stderr=asyncio.subprocess.DEVNULL,
210
+ )
211
+ self.pid = self._proc.pid
212
+ try:
213
+ port = await self._await_devtools_port(Path(user_data_dir))
214
+ self.ws_url = await self._await_ws_url(port)
215
+ except Exception:
216
+ await self.terminate()
217
+ raise
218
+ return self.ws_url
219
+
220
+ async def _await_devtools_port(
221
+ self, user_data_dir: Path, *, timeout: float = 30.0
222
+ ) -> int:
223
+ """Wait for Chrome to write the chosen port to ``DevToolsActivePort``.
224
+
225
+ The file's first line is the port; it appears once the DevTools endpoint
226
+ is listening, so its presence doubles as a readiness signal. When a
227
+ fixed port was requested we still wait for the file to confirm liveness.
228
+ """
229
+ active_port_file = user_data_dir / "DevToolsActivePort"
230
+ loop = asyncio.get_running_loop()
231
+ deadline = loop.time() + timeout
232
+ while True:
233
+ if self._proc is not None and self._proc.returncode is not None:
234
+ raise RuntimeError(
235
+ f"browser exited during startup (code {self._proc.returncode})"
236
+ )
237
+ try:
238
+ first_line = active_port_file.read_text().splitlines()[0]
239
+ return int(first_line)
240
+ except (FileNotFoundError, IndexError, ValueError):
241
+ pass
242
+ if loop.time() >= deadline:
243
+ raise TimeoutError("browser did not open its DevTools port in time")
244
+ await asyncio.sleep(0.05)
245
+
246
+ async def _await_ws_url(self, port: int, *, timeout: float = 10.0) -> str:
247
+ """Poll ``/json/version`` until ``webSocketDebuggerUrl`` is served."""
248
+ endpoint = f"http://127.0.0.1:{port}/json/version"
249
+ loop = asyncio.get_running_loop()
250
+ deadline = loop.time() + timeout
251
+ while True:
252
+ try:
253
+ info = await asyncio.to_thread(self._read_json, endpoint)
254
+ return info["webSocketDebuggerUrl"]
255
+ except (urllib.error.URLError, KeyError, ConnectionError, OSError):
256
+ if loop.time() >= deadline:
257
+ raise TimeoutError(f"{endpoint} did not become available")
258
+ await asyncio.sleep(0.05)
259
+
260
+ @staticmethod
261
+ def _read_json(url: str) -> dict:
262
+ with urllib.request.urlopen(url, timeout=2) as resp:
263
+ return json.loads(resp.read())
264
+
265
+ def is_alive(self) -> bool:
266
+ """Whether the browser process is still running (used by the supervisor)."""
267
+ if self._proc is not None and self._proc.returncode is not None:
268
+ return False
269
+ if self.pid is None:
270
+ return False
271
+ return psutil.pid_exists(self.pid)
272
+
273
+ async def terminate(self, *, timeout: float = 5.0) -> None:
274
+ """Terminate the browser process gracefully, then kill if it lingers."""
275
+ proc = self._proc
276
+ if proc is not None and proc.returncode is None:
277
+ try:
278
+ proc.send_signal(signal.SIGTERM)
279
+ except ProcessLookupError:
280
+ pass
281
+ else:
282
+ try:
283
+ await asyncio.wait_for(proc.wait(), timeout)
284
+ except asyncio.TimeoutError:
285
+ with _suppress_lookup():
286
+ proc.kill()
287
+ await proc.wait()
288
+ self._cleanup_profile()
289
+
290
+ def _cleanup_profile(self) -> None:
291
+ if self._owned_user_data_dir is not None:
292
+ shutil.rmtree(self._owned_user_data_dir, ignore_errors=True)
293
+ self._owned_user_data_dir = None
294
+
295
+
296
+ class _suppress_lookup:
297
+ """Context manager swallowing ``ProcessLookupError`` (process already gone)."""
298
+
299
+ def __enter__(self) -> "_suppress_lookup":
300
+ return self
301
+
302
+ def __exit__(self, exc_type, exc, tb) -> bool:
303
+ return exc_type is not None and issubclass(exc_type, ProcessLookupError)
@@ -0,0 +1,239 @@
1
+ """Prometheus metrics for the server: browsers, targets, CDP events, CPU/RAM.
2
+
3
+ Exposed at ``GET /metrics``. The exposition is split in two so a scrape never
4
+ blocks the event loop:
5
+
6
+ * **live counters** -- :meth:`ServerMetrics.record_event` is called from the page
7
+ bridges on every CDP event, bumping an in-memory ``(browser, target, domain)``
8
+ counter synchronously;
9
+ * **a periodic snapshot** -- a background task polls each browser's
10
+ ``/json/list`` (targets + their types + nesting via ``parentId``) and samples
11
+ the browser process tree with :mod:`psutil` (CPU%, RSS), off the loop in a
12
+ thread. :meth:`collect` (called at scrape time) only reads these cached
13
+ values.
14
+
15
+ Metrics:
16
+
17
+ ================================== ===== ===========================================
18
+ ``parsek_browsers`` gauge supervised browsers
19
+ ``parsek_targets`` gauge targets, labelled ``browser``, ``type``
20
+ ``parsek_nested_targets`` gauge targets with a ``parentId``, per ``browser``
21
+ ``parsek_browser_cpu_percent`` gauge CPU% of the browser tree, per ``browser``
22
+ ``parsek_browser_memory_bytes`` gauge RSS of the browser tree, per ``browser``
23
+ ``parsek_cdp_events_total`` count CDP events, labelled ``browser``, ``target``, ``domain``
24
+ ================================== ===== ===========================================
25
+
26
+ Cardinality note: ``parsek_cdp_events_total`` is labelled by target id, which is
27
+ unbounded over time. Series for targets that disappear from ``/json/list`` are
28
+ pruned on every snapshot, so the live set tracks the browser, not history.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import urllib.error
36
+ import urllib.request
37
+ from collections import defaultdict
38
+ from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
39
+
40
+ import psutil
41
+ from aiohttp import web
42
+ from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, generate_latest
43
+ from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
44
+
45
+ from parsek_cdp._logging import get_logger
46
+
47
+ if TYPE_CHECKING:
48
+ from .proxy import ParsekServer
49
+
50
+ logger = get_logger(__name__)
51
+
52
+ #: (browser_uuid, target_id, domain) -> event count.
53
+ EventKey = Tuple[str, str, str]
54
+
55
+
56
+ class _BrowserSnapshot:
57
+ """Cached per-browser facts sampled by the refresh task."""
58
+
59
+ __slots__ = ("targets_by_type", "nested", "cpu_percent", "memory_bytes", "target_ids")
60
+
61
+ def __init__(self) -> None:
62
+ self.targets_by_type: Dict[str, int] = {}
63
+ self.nested: int = 0
64
+ self.cpu_percent: float = 0.0
65
+ self.memory_bytes: int = 0
66
+ self.target_ids: Set[str] = set()
67
+
68
+
69
+ class ServerMetrics:
70
+ """Collects and exposes Prometheus metrics for a :class:`ParsekServer`."""
71
+
72
+ def __init__(self, server: "ParsekServer", *, refresh_interval: float = 5.0) -> None:
73
+ self._server = server
74
+ self._refresh_interval = refresh_interval
75
+ self._events: Dict[EventKey, int] = defaultdict(int)
76
+ self._snapshot: Dict[str, _BrowserSnapshot] = {}
77
+ #: psutil.Process cache so cpu_percent() deltas are meaningful across sweeps.
78
+ self._proc_cache: Dict[int, psutil.Process] = {}
79
+ self._refresh_task: Optional[asyncio.Task] = None
80
+ self._registry = CollectorRegistry()
81
+ self._registry.register(_Collector(self))
82
+
83
+ # -- live event counting ---------------------------------------------- #
84
+
85
+ def record_event(self, browser_uuid: str, target_id: str, domain: str) -> None:
86
+ """Count one CDP event (called from a page bridge per event frame)."""
87
+ self._events[(browser_uuid, target_id, domain)] += 1
88
+
89
+ # -- lifecycle --------------------------------------------------------- #
90
+
91
+ async def start(self) -> None:
92
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
93
+
94
+ async def stop(self) -> None:
95
+ if self._refresh_task is not None:
96
+ self._refresh_task.cancel()
97
+ try:
98
+ await self._refresh_task
99
+ except asyncio.CancelledError:
100
+ pass
101
+ self._refresh_task = None
102
+
103
+ async def handle(self, request: web.Request) -> web.Response:
104
+ """``GET /metrics`` -- render the registry in the Prometheus text format.
105
+
106
+ Rendered synchronously on the event loop (not via ``to_thread``) so that
107
+ ``collect`` reading ``_events`` cannot race with :meth:`record_event`
108
+ mutating it from the same loop -- the registry is small, so this is cheap.
109
+ """
110
+ body = generate_latest(self._registry)
111
+ return web.Response(body=body, content_type=CONTENT_TYPE_LATEST.split(";")[0],
112
+ charset="utf-8")
113
+
114
+ # -- snapshot refresh -------------------------------------------------- #
115
+
116
+ async def _refresh_loop(self) -> None:
117
+ while True:
118
+ try:
119
+ await self._refresh_once()
120
+ except Exception:
121
+ logger.exception("metrics refresh failed")
122
+ await asyncio.sleep(self._refresh_interval)
123
+
124
+ async def _refresh_once(self) -> None:
125
+ snapshot: Dict[str, _BrowserSnapshot] = {}
126
+ live_targets: Set[Tuple[str, str]] = set()
127
+ for browser_uuid, supervisor in list(self._server.supervisors.items()):
128
+ snap = _BrowserSnapshot()
129
+ origin = supervisor.http_origin
130
+ if origin is not None:
131
+ await self._sample_targets(origin, snap)
132
+ if supervisor.pid is not None:
133
+ await asyncio.to_thread(self._sample_resources, supervisor.pid, snap)
134
+ snapshot[browser_uuid] = snap
135
+ live_targets.update((browser_uuid, tid) for tid in snap.target_ids)
136
+ self._snapshot = snapshot
137
+ self._prune_events(live_targets)
138
+
139
+ async def _sample_targets(self, http_origin: str, snap: _BrowserSnapshot) -> None:
140
+ """Fill ``snap`` from ``{http_origin}/json/list`` (excludes the browser target)."""
141
+ try:
142
+ targets = await asyncio.to_thread(self._read_json, f"{http_origin}/json/list")
143
+ except (urllib.error.URLError, OSError, ValueError):
144
+ return
145
+ by_type: Dict[str, int] = defaultdict(int)
146
+ for target in targets:
147
+ by_type[target.get("type", "other")] += 1
148
+ tid = target.get("id")
149
+ if tid:
150
+ snap.target_ids.add(tid)
151
+ if target.get("parentId"):
152
+ snap.nested += 1
153
+ snap.targets_by_type = dict(by_type)
154
+
155
+ def _sample_resources(self, pid: int, snap: _BrowserSnapshot) -> None:
156
+ """Sum CPU% and RSS over the browser process tree (renderers, gpu, ...)."""
157
+ try:
158
+ root = self._proc(pid)
159
+ procs = [root] + root.children(recursive=True)
160
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
161
+ return
162
+ cpu = 0.0
163
+ rss = 0
164
+ for proc in procs:
165
+ try:
166
+ cpu += self._proc(proc.pid).cpu_percent(None)
167
+ rss += proc.memory_info().rss
168
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
169
+ continue
170
+ snap.cpu_percent = cpu
171
+ snap.memory_bytes = rss
172
+
173
+ def _proc(self, pid: int) -> psutil.Process:
174
+ """Return a cached :class:`psutil.Process` so cpu_percent() deltas persist."""
175
+ proc = self._proc_cache.get(pid)
176
+ if proc is None or not proc.is_running():
177
+ proc = psutil.Process(pid)
178
+ proc.cpu_percent(None) # prime the delta baseline
179
+ self._proc_cache[pid] = proc
180
+ return proc
181
+
182
+ def _prune_events(self, live: Set[Tuple[str, str]]) -> None:
183
+ """Drop event series for targets no longer present (bounds cardinality)."""
184
+ stale = [k for k in self._events if (k[0], k[1]) not in live]
185
+ for key in stale:
186
+ del self._events[key]
187
+
188
+ @staticmethod
189
+ def _read_json(url: str) -> list:
190
+ with urllib.request.urlopen(url, timeout=2) as resp:
191
+ return json.loads(resp.read())
192
+
193
+
194
+ class _Collector:
195
+ """Bridges :class:`ServerMetrics` state into Prometheus metric families."""
196
+
197
+ def __init__(self, metrics: ServerMetrics) -> None:
198
+ self._metrics = metrics
199
+
200
+ def collect(self):
201
+ m = self._metrics
202
+ snapshot = m._snapshot
203
+
204
+ browsers = GaugeMetricFamily("parsek_browsers", "Number of supervised browsers")
205
+ browsers.add_metric([], float(len(m._server.supervisors)))
206
+ yield browsers
207
+
208
+ targets = GaugeMetricFamily(
209
+ "parsek_targets", "Targets per browser by type", labels=["browser", "type"]
210
+ )
211
+ nested = GaugeMetricFamily(
212
+ "parsek_nested_targets", "Targets with a parent (nested)", labels=["browser"]
213
+ )
214
+ cpu = GaugeMetricFamily(
215
+ "parsek_browser_cpu_percent", "CPU%% of the browser process tree",
216
+ labels=["browser"],
217
+ )
218
+ mem = GaugeMetricFamily(
219
+ "parsek_browser_memory_bytes", "RSS of the browser process tree",
220
+ labels=["browser"],
221
+ )
222
+ for browser_uuid, snap in snapshot.items():
223
+ for type_, count in snap.targets_by_type.items():
224
+ targets.add_metric([browser_uuid, type_], float(count))
225
+ nested.add_metric([browser_uuid], float(snap.nested))
226
+ cpu.add_metric([browser_uuid], snap.cpu_percent)
227
+ mem.add_metric([browser_uuid], float(snap.memory_bytes))
228
+ yield targets
229
+ yield nested
230
+ yield cpu
231
+ yield mem
232
+
233
+ events = CounterMetricFamily(
234
+ "parsek_cdp_events", "CDP events seen, by target and domain",
235
+ labels=["browser", "target", "domain"],
236
+ )
237
+ for (browser_uuid, target_id, domain), count in list(m._events.items()):
238
+ events.add_metric([browser_uuid, target_id, domain], float(count))
239
+ yield events