browserwright 0.6.2__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.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,353 @@
1
+ """launch-chrome subcommand — H9 install-wizard helper.
2
+
3
+ Spec §5.5: locate Chrome → allocate user-data-dir → spawn detached → poll
4
+ `DevToolsActivePort` → output ws URL → write pid file → exit. The Chrome
5
+ process stays alive (detached, in its own process group), so Skill can later
6
+ `kill $(cat pidfile)` to shut it down.
7
+
8
+ Important constraints (spec §5.5):
9
+ - We don't auto-attach. We just launch and print the URL.
10
+ - We don't take custom --no-sandbox / --lang flags. (Open question §10 → punt.)
11
+ - After exit, DevToolsActivePort is Chrome's responsibility to clean up.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import platform
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+
24
+ import httpx
25
+
26
+ from .config import Config, check_name
27
+ from .errors import ChromeBinaryNotFound, UserError, Unavailable
28
+ from .platforms import cache_dir, discover_chrome_binary, profile_paths, runtime_dir
29
+
30
+
31
+ DEFAULT_PORT = 0 # let the OS pick when --port not given (spec §5.5 step 3)
32
+ DEFAULT_TIMEOUT = 30.0
33
+
34
+
35
+ async def launch_chrome(
36
+ cfg: Config,
37
+ *,
38
+ profile: str = "isolated",
39
+ persistent: bool = True,
40
+ chrome_binary: str | None = None,
41
+ port: int | None = None,
42
+ timeout: float = DEFAULT_TIMEOUT,
43
+ allow_default_profile: bool = False,
44
+ extra_args: list[str] | None = None,
45
+ ) -> dict:
46
+ """Launch Chrome detached with --remote-debugging-port + isolated profile.
47
+
48
+ Returns the same shape as `url --json`:
49
+ {schema_version: 1, ws_url, backend: "rdp", extras: {isolated_profile, profile_path, pid}}
50
+
51
+ `allow_default_profile=True` (or env `BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1`)
52
+ is the expert escape hatch for the §11 guard — see `_check_not_default_profile`.
53
+
54
+ `extra_args` (optional list) is appended to the Chrome argv verbatim, after
55
+ the framework's own flags. Used by the E2E harness to inject
56
+ `--load-extension=...`. Caller is responsible for shell-escaping.
57
+ """
58
+ check_name(profile)
59
+
60
+ # 1) Chrome binary.
61
+ binary = discover_chrome_binary(chrome_binary or cfg.chrome_binary)
62
+ if binary is None:
63
+ raise ChromeBinaryNotFound(
64
+ "could not locate a Chrome binary. Set BD_CHROME_BINARY or pass "
65
+ "--chrome-binary."
66
+ )
67
+
68
+ # 2) user-data-dir.
69
+ user_data_dir = _allocate_data_dir(profile, persistent=persistent)
70
+ user_data_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ # 2.1) **Default-profile guard** (v0.5 — Task #11).
73
+ # If `user_data_dir` is the OS-default Chrome profile, refuse. Launching
74
+ # Chrome with `--remote-debugging-port` against the user's daily profile
75
+ # permanently taints it: Chrome writes `DevToolsActivePort`, starts
76
+ # LISTEN on the requested port, and every subsequent ws upgrade triggers
77
+ # Chrome's "Allow remote debugging?" popup. This is the **root cause**
78
+ # of the 2026-05-18 popup storm — see chrome-popup-accumulation-bug
79
+ # memory for forensics. The escape hatch exists for the rare expert use
80
+ # case (someone deliberately wants their daily Chrome on CDP).
81
+ _check_not_default_profile(
82
+ user_data_dir,
83
+ allow=(allow_default_profile or _truthy_env(
84
+ "BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE")),
85
+ )
86
+
87
+ # 3) Port.
88
+ use_port = DEFAULT_PORT if port is None else port
89
+
90
+ # 4) Spawn detached.
91
+ args = [
92
+ str(binary),
93
+ f"--user-data-dir={user_data_dir}",
94
+ f"--remote-debugging-port={use_port}",
95
+ # `--no-first-run` + `--no-default-browser-check` keep an isolated
96
+ # profile from showing welcome dialogs on first launch. They don't
97
+ # affect remote-debugging behavior — just UI.
98
+ "--no-first-run",
99
+ "--no-default-browser-check",
100
+ # Chrome 121+ rejects the ws upgrade with HTTP 403 unless the caller's
101
+ # Origin is on the allow-list (origin-based CSRF defense). Skill /
102
+ # cdp-use opens the ws from a Python process with no Origin header —
103
+ # which Chrome 121+ treats as *not allowed* by default. We pass `*`
104
+ # because the user-data-dir is already an isolation boundary (no
105
+ # session cookies / no auto-login) — same posture as DevTools itself.
106
+ "--remote-allow-origins=*",
107
+ # Disable OS keychain integration. On macOS, Chrome otherwise prompts
108
+ # for the login keychain password on every fresh-profile start ("…wants
109
+ # to use confidential information stored in Chromium Safe Storage…"),
110
+ # which blocks automation. The isolated user-data-dir has nothing
111
+ # encrypted to begin with, so the basic/mock store is functionally
112
+ # equivalent. Same defaults Playwright / Puppeteer / browser-use ship.
113
+ "--password-store=basic",
114
+ "--use-mock-keychain",
115
+ ]
116
+ if extra_args:
117
+ args.extend(extra_args)
118
+ proc = subprocess.Popen(
119
+ args,
120
+ stdout=subprocess.DEVNULL,
121
+ stderr=subprocess.DEVNULL,
122
+ **_spawn_kwargs(),
123
+ )
124
+
125
+ # 5) Wait for Chrome to be reachable.
126
+ #
127
+ # Primary signal: DevToolsActivePort file (gives us both the chosen port
128
+ # and the ws path in one read). Secondary signal: /json/version on the
129
+ # known port — only available when --port N was explicit.
130
+ #
131
+ # The secondary path covers a Chrome 148 macOS quirk where, with the
132
+ # user's primary Chrome already running, the spawned child Chrome answers
133
+ # `/json/version` on the requested port but never writes
134
+ # DevToolsActivePort (Skill team field report May 2026). When --port 0
135
+ # we have no fallback because we don't know what port Chrome picked.
136
+ actual_port, ws_path = await _wait_for_chrome_ready(
137
+ proc, user_data_dir, requested_port=port, timeout=timeout,
138
+ )
139
+
140
+ # 6) Build ws URL.
141
+ ws_url = f"ws://127.0.0.1:{actual_port}{ws_path}"
142
+
143
+ # 7) Write pid file. Best-effort; missing dir / permission denied just
144
+ # surfaces as a warning in extras.
145
+ pid = proc.pid
146
+ pidfile_err: str | None = None
147
+ pidfile = runtime_dir() / f"browserwright-daemon-chrome-{profile}.pid"
148
+ try:
149
+ pidfile.parent.mkdir(parents=True, exist_ok=True)
150
+ pidfile.write_text(f"{pid}\n")
151
+ except OSError as e:
152
+ pidfile_err = f"could not write pid file {pidfile}: {e}"
153
+
154
+ return {
155
+ "schema_version": 1,
156
+ "ws_url": ws_url,
157
+ "backend": "rdp",
158
+ "extras": {
159
+ "isolated_profile": True,
160
+ "profile_path": str(user_data_dir),
161
+ "pid": pid,
162
+ "pid_file": str(pidfile) if pidfile_err is None else None,
163
+ "pid_file_error": pidfile_err,
164
+ },
165
+ }
166
+
167
+
168
+ # ---- helpers ---------------------------------------------------------------
169
+
170
+
171
+ async def _wait_for_chrome_ready(
172
+ proc: subprocess.Popen,
173
+ user_data_dir: Path,
174
+ *,
175
+ requested_port: int | None,
176
+ timeout: float,
177
+ ) -> tuple[str, str]:
178
+ """Poll DevToolsActivePort first; fall back to /json/version when an
179
+ explicit port was requested. Returns (port_str, ws_path).
180
+
181
+ Why two signals?
182
+ - DevToolsActivePort is the canonical Chrome signal — it carries the
183
+ ws path so we can build the URL without an extra HTTP roundtrip.
184
+ - But Chrome 148 on macOS, when invoked while the user's primary Chrome
185
+ is already running, sometimes never writes the file (the new instance
186
+ gets bootstrapped through a different code path). It DOES answer
187
+ `/json/version` on the requested port — so when --port was explicit,
188
+ we can resolve via the HTTP discovery shape that rdp already uses.
189
+
190
+ We poll both in the same loop instead of waiting full `timeout` on one
191
+ then the other. Whichever wins first answers; the other never runs.
192
+ """
193
+ active_file = user_data_dir / "DevToolsActivePort"
194
+ fallback_url = (
195
+ f"http://127.0.0.1:{requested_port}/json/version"
196
+ if requested_port is not None and requested_port > 0
197
+ else None
198
+ )
199
+ deadline = time.monotonic() + timeout
200
+ last_http_err: str | None = None
201
+ # Chrome 148 macOS quirk (Skill team field report May 2026):
202
+ # the launcher binary fork-exec's the real Chrome process and then
203
+ # exits with code 126. The grandchild Chrome continues running, writes
204
+ # DevToolsActivePort, and answers /json/version normally. Our `proc`
205
+ # handle is the short-lived parent. So we must NOT fail the loop the
206
+ # instant `proc.poll() is not None`; we have to keep checking the
207
+ # DevToolsActivePort + HTTP signals until the actual `timeout`. We
208
+ # still record the proc exit so the timeout error message can be
209
+ # precise.
210
+ child_exited_at: float | None = None
211
+ child_exit_code: int | None = None
212
+
213
+ while time.monotonic() < deadline:
214
+ # Note when the child died (could be benign fork-exec hand-off OR a
215
+ # real failure like SingletonLock). Keep polling either way.
216
+ if proc.poll() is not None and child_exited_at is None:
217
+ child_exited_at = time.monotonic()
218
+ child_exit_code = proc.returncode
219
+
220
+ # Primary: DevToolsActivePort.
221
+ try:
222
+ lines = active_file.read_text().splitlines()
223
+ if len(lines) >= 2 and lines[0].strip() and lines[1].strip():
224
+ return lines[0].strip(), lines[1].strip()
225
+ except (FileNotFoundError, OSError):
226
+ pass
227
+
228
+ # Secondary: /json/version on the known port. We only try this when
229
+ # --port was explicit — with --port 0 we don't know what port Chrome
230
+ # picked, so DevToolsActivePort is our only option.
231
+ if fallback_url is not None:
232
+ try:
233
+ async with httpx.AsyncClient(timeout=0.5, trust_env=False) as client:
234
+ resp = await client.get(fallback_url)
235
+ if resp.status_code == 200:
236
+ body = resp.json()
237
+ ws_url_full = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
238
+ if isinstance(ws_url_full, str) and ws_url_full:
239
+ # ws_url_full is `ws://127.0.0.1:N/devtools/browser/UUID`
240
+ # — split into port + path for the caller's URL builder.
241
+ from urllib.parse import urlparse
242
+ parsed = urlparse(ws_url_full)
243
+ port_str = str(parsed.port or requested_port)
244
+ ws_path = parsed.path
245
+ return port_str, ws_path
246
+ except (httpx.HTTPError, OSError) as e:
247
+ last_http_err = f"{type(e).__name__}: {e}"
248
+
249
+ await asyncio.sleep(0.1)
250
+
251
+ # Timed out. Distinguish two failure modes for the error message:
252
+ # (a) child exited AND nothing answered → likely SingletonLock or
253
+ # Chrome flag rejection. Mention the exit code prominently.
254
+ # (b) child still alive but no DevToolsActivePort / no /json/version →
255
+ # Chrome is running but not serving CDP — bad flags, port already
256
+ # bound by something else, sandbox failure, etc.
257
+ # Only terminate when we know the proc is still ours to kill (case b).
258
+ if child_exited_at is None:
259
+ with _silent():
260
+ proc.terminate()
261
+
262
+ reasons: list[str] = []
263
+ if child_exit_code is not None:
264
+ reasons.append(
265
+ f"launcher process exited with code {child_exit_code} after "
266
+ f"~{child_exited_at and (child_exited_at - (deadline - timeout)):.1f}s "
267
+ f"(grandchild Chrome may have survived; check `ps aux | grep -i chrome`). "
268
+ f"If another Chrome instance owns {user_data_dir}, remove "
269
+ f"`SingletonLock` from that dir or pass a different `--profile`."
270
+ )
271
+ reasons.append(f"DevToolsActivePort never appeared in {user_data_dir}")
272
+ if fallback_url is not None:
273
+ suffix = f" (last HTTP error: {last_http_err})" if last_http_err else ""
274
+ reasons.append(f"and {fallback_url} did not become reachable{suffix}")
275
+ raise Unavailable(
276
+ f"launch-chrome: Chrome not ready after {timeout}s — "
277
+ + "; ".join(reasons)
278
+ )
279
+
280
+
281
+ def _truthy_env(name: str) -> bool:
282
+ """Common truthy parser for env-var flags. Recognizes 1/true/yes/on/y
283
+ (case-insensitive); empty string and unset are False. Matches the
284
+ informal convention most CLIs use — REVIEW.md F-9 #11 found we
285
+ previously only accepted `"1"`/`"true"`/`"True"`, silently rejecting
286
+ `"yes"` / `"on"` / `"TRUE"`."""
287
+ return os.environ.get(name, "").strip().lower() in {
288
+ "1", "true", "yes", "on", "y",
289
+ }
290
+
291
+
292
+ def _check_not_default_profile(user_data_dir: Path, *, allow: bool) -> None:
293
+ """Refuse if `user_data_dir` is the OS-default Chrome / Edge / Brave / Arc
294
+ profile root. The platforms table is the source of truth — we resolve both
295
+ sides to absolute paths and compare with `os.path.samefile()` if both
296
+ exist, then fall back to string-equality on the resolved Path.
297
+
298
+ Raises `UserError` when the dir matches a default-profile location and
299
+ `allow=False`. No-op otherwise.
300
+ """
301
+ try:
302
+ target = user_data_dir.expanduser().resolve(strict=False)
303
+ except (OSError, RuntimeError):
304
+ target = user_data_dir
305
+ target_str = str(target)
306
+ for default in profile_paths():
307
+ try:
308
+ d = default.expanduser().resolve(strict=False)
309
+ except (OSError, RuntimeError):
310
+ d = default
311
+ if str(d) == target_str:
312
+ if allow:
313
+ return
314
+ raise UserError(
315
+ f"refusing to launch-chrome against the user's default profile "
316
+ f"({target_str}). Chrome will be permanently tainted with "
317
+ f"--remote-debugging-port (every ws upgrade triggers an "
318
+ f"'Allow remote debugging?' popup; the LISTEN socket persists "
319
+ f"across the Chrome process's lifetime). Use a different "
320
+ f"`--profile <isolated_name>` or `--tmp` instead. If you "
321
+ f"truly know what you're doing, set "
322
+ f"`BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1` — but note this "
323
+ f"may permanently expose your daily Chrome to CDP popup "
324
+ f"hazard (see chrome-popup-accumulation-bug memory).")
325
+
326
+
327
+ def _allocate_data_dir(profile: str, *, persistent: bool) -> Path:
328
+ if persistent:
329
+ return cache_dir() / "profiles" / profile
330
+ # --tmp: a fresh per-launch dir, NOT auto-cleaned (spec §5.5 step 2). User
331
+ # cleans up by hand to avoid the race between Chrome shutdown writeback and
332
+ # our rm -rf.
333
+ return Path(tempfile.mkdtemp(prefix=f"browserwright-daemon-{profile}-"))
334
+
335
+
336
+ def _spawn_kwargs() -> dict:
337
+ """Detach the spawn from this terminal — mirrors browser-harness
338
+ `_ipc.py:68-76` `spawn_kwargs()`.
339
+ """
340
+ if platform.system() == "Windows":
341
+ return {
342
+ "creationflags": (
343
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
344
+ | subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
345
+ )
346
+ }
347
+ return {"start_new_session": True}
348
+
349
+
350
+ class _silent:
351
+ """Context manager that swallows OSErrors during cleanup."""
352
+ def __enter__(self): return self
353
+ def __exit__(self, exc_type, exc, tb): return exc_type is not None and issubclass(exc_type, OSError)
@@ -0,0 +1,181 @@
1
+ """Observability (v0.5): metrics counters + structured JSON logging.
2
+
3
+ Spec §7 v0.5 line 2 — "observability / metrics / structured logging".
4
+
5
+ Three pieces:
6
+
7
+ 1. **Counters** (`Metrics`) — bucketed integer counters incremented inline
8
+ at hot-path call sites (client connect, upstream open, pre-open buffer
9
+ overflow, etc.). Pure dataclass; reads / writes don't lock because
10
+ asyncio gives us single-threaded mutation guarantees within the daemon
11
+ process.
12
+
13
+ 2. **JSON log formatter** — opt-in via `BD_LOG_JSON=1`. Emits one JSON
14
+ object per log record, schema:
15
+
16
+ {"ts": "...", "level": "...", "logger": "...", "msg": "...",
17
+ "extra": {...optional structured kwargs...}}
18
+
19
+ The default human formatter stays unchanged.
20
+
21
+ 3. **`stats` snapshot** — `snapshot()` returns a dict-of-dicts the
22
+ `browserwright-daemon stats` CLI subcommand serializes to JSON. Used for
23
+ external monitoring (`watch -n 5 'browserwright-daemon stats --json'`) and
24
+ in tests to assert hot paths actually incremented their counters.
25
+
26
+ Design constraints:
27
+ - No external metrics deps (prometheus_client, opentelemetry). The daemon
28
+ is supposed to be lightweight (§8.5 "no logging framework").
29
+ - Counters are coarse on purpose — every counter has a clear hot-path
30
+ call site. We don't try to time-bucket / histogram / export over the
31
+ wire. That's all v0.6+ territory.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import os
38
+ import sys
39
+ import time
40
+ from dataclasses import dataclass, field, asdict
41
+
42
+
43
+ # ---- counters --------------------------------------------------------------
44
+
45
+
46
+ @dataclass
47
+ class Metrics:
48
+ """All daemon counters live here. One instance per daemon process,
49
+ accessed via `metrics()` singleton.
50
+
51
+ Naming convention: `<area>_<event>` (snake_case). Areas are stable —
52
+ `client`, `upstream`, `proxy`, `auth` (four groups; v0.5.3 F-14 dropped
53
+ the stale `relay_*` mention from this docstring — relay activity is
54
+ counted under `proxy_*` / `upstream_*` instead). Adding a counter is a
55
+ minor version bump for the `stats --json` schema; renaming one is
56
+ major.
57
+ """
58
+ started_at: float = field(default_factory=time.time)
59
+
60
+ # ---- client (downstream skill connections) ----
61
+ client_connected_total: int = 0
62
+ client_disconnected_total: int = 0
63
+ client_frame_received_total: int = 0
64
+
65
+ # ---- upstream (Chrome / cloud / relay) ----
66
+ upstream_open_attempts_total: int = 0
67
+ upstream_open_succeeded_total: int = 0
68
+ upstream_open_failed_total: int = 0
69
+ upstream_closed_total: int = 0
70
+ upstream_frame_received_total: int = 0
71
+ upstream_frame_sent_total: int = 0
72
+
73
+ # ---- proxy (router level) ----
74
+ proxy_attach_succeeded_total: int = 0
75
+ proxy_attach_rejected_total: int = 0
76
+ proxy_pre_open_buffered_total: int = 0
77
+ proxy_pre_open_overflow_total: int = 0
78
+ proxy_pre_open_drained_total: int = 0
79
+
80
+ # ---- auth (v0.5 cloud backend) ----
81
+ auth_headers_resolved_total: int = 0
82
+ auth_resolution_failures_total: int = 0
83
+
84
+ def snapshot(self) -> dict:
85
+ """Return a flat dict suitable for JSON serialization.
86
+
87
+ Includes `uptime_seconds` derived from `started_at`.
88
+ """
89
+ d = asdict(self)
90
+ d["uptime_seconds"] = round(time.time() - self.started_at, 3)
91
+ return d
92
+
93
+ def reset(self) -> None:
94
+ """Re-init every counter back to 0 + started_at to now. Mostly a
95
+ test seam — production daemons rotate by restart, not by reset."""
96
+ for k in list(self.__dataclass_fields__.keys()):
97
+ if k == "started_at":
98
+ self.started_at = time.time()
99
+ else:
100
+ setattr(self, k, 0)
101
+
102
+
103
+ _singleton: Metrics | None = None
104
+
105
+
106
+ def metrics() -> Metrics:
107
+ """Lazy singleton accessor. The first call creates the instance; every
108
+ subsequent call returns the same one."""
109
+ global _singleton
110
+ if _singleton is None:
111
+ _singleton = Metrics()
112
+ return _singleton
113
+
114
+
115
+ def reset_metrics_for_test() -> None:
116
+ """Test seam — wipes the singleton so each test starts at zero."""
117
+ global _singleton
118
+ _singleton = None
119
+
120
+
121
+ # ---- JSON log formatter ---------------------------------------------------
122
+
123
+
124
+ class JSONLogFormatter(logging.Formatter):
125
+ """Emit one JSON object per log record. Keeps log lines greppable by
126
+ field (`jq '.msg'`) and friendly to log aggregators.
127
+
128
+ Schema (stable in v0.5):
129
+ ts: ISO-8601 UTC
130
+ level: uppercase level name
131
+ logger: logger name (e.g. "browserwright.daemon.server.proxy")
132
+ msg: formatted message
133
+ extra: any non-standard `record.__dict__` entries the caller added
134
+ via `logger.info("...", extra={"client_id": 7})`
135
+ """
136
+
137
+ _STANDARD = {
138
+ "name", "msg", "args", "levelname", "levelno", "pathname",
139
+ "filename", "module", "exc_info", "exc_text", "stack_info",
140
+ "lineno", "funcName", "created", "msecs", "relativeCreated",
141
+ "thread", "threadName", "processName", "process", "asctime",
142
+ "message",
143
+ }
144
+
145
+ def format(self, record: logging.LogRecord) -> str:
146
+ payload: dict = {
147
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
148
+ "level": record.levelname,
149
+ "logger": record.name,
150
+ "msg": record.getMessage(),
151
+ }
152
+ extra = {k: v for k, v in record.__dict__.items()
153
+ if k not in self._STANDARD and not k.startswith("_")}
154
+ if extra:
155
+ payload["extra"] = extra
156
+ if record.exc_info:
157
+ payload["exc_info"] = self.formatException(record.exc_info)
158
+ return json.dumps(payload, default=str, ensure_ascii=False)
159
+
160
+
161
+ def install_json_logging_if_requested(stream=None) -> bool:
162
+ """If `BD_LOG_JSON=1`, replace stderr / file handlers' formatter with
163
+ `JSONLogFormatter`. Idempotent. Returns True iff anything changed.
164
+
165
+ `stream` defaults to `sys.stderr` (the daemon's normal log channel).
166
+ Callers can override for tests.
167
+ """
168
+ if os.environ.get("BD_LOG_JSON", "") not in ("1", "true", "True"):
169
+ return False
170
+ stream = stream or sys.stderr
171
+ formatter = JSONLogFormatter()
172
+ root = logging.getLogger()
173
+ # If no handlers, create one targeting the requested stream.
174
+ if not root.handlers:
175
+ h = logging.StreamHandler(stream)
176
+ h.setFormatter(formatter)
177
+ root.addHandler(h)
178
+ else:
179
+ for h in root.handlers:
180
+ h.setFormatter(formatter)
181
+ return True