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.
- parsek_cdp_server/__init__.py +46 -0
- parsek_cdp_server/launcher.py +303 -0
- parsek_cdp_server/metrics.py +239 -0
- parsek_cdp_server/proxy.py +476 -0
- parsek_cdp_server/py.typed +0 -0
- parsek_cdp_server/reaper.py +173 -0
- parsek_cdp_server/supervisor.py +190 -0
- parsek_cdp_server-0.1.0.dist-info/METADATA +139 -0
- parsek_cdp_server-0.1.0.dist-info/RECORD +12 -0
- parsek_cdp_server-0.1.0.dist-info/WHEEL +5 -0
- parsek_cdp_server-0.1.0.dist-info/licenses/LICENSE +201 -0
- parsek_cdp_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|