cloudagent-daemon 0.2.1__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,315 @@
1
+ """Wave C #3 — in-process metric registry + Prometheus text renderer.
2
+
3
+ Lightweight intentionally: we avoid pulling in ``prometheus_client`` (a
4
+ ~30 KB dep) for a daemon that ships in user homedirs. The registry is
5
+ process-local — single asyncio loop, no threading — so a plain dict
6
+ backed by a per-name lock isn't necessary; we still wrap mutating
7
+ operations in an :class:`asyncio.Lock` for safety against test
8
+ fixtures that exercise the same registry concurrently.
9
+
10
+ Type model:
11
+ * :class:`_Counter` — monotonic int, ``inc(n=1)`` is the only mutation.
12
+ Optional ``labels: dict[str, str]`` (rendered as Prometheus label set).
13
+ * :class:`_Gauge` — float, ``set(v)`` and ``inc(n=...)`` allowed.
14
+
15
+ Rendering: :func:`render_prometheus_text` produces the
16
+ ``text/plain; version=0.0.4`` format scraped by Prometheus 2.x —
17
+ ``# HELP``, ``# TYPE``, then one sample per (name, labels) pair.
18
+
19
+ This module is deliberately not exposed via ``__all__`` from the
20
+ package root — callers should import from ``cloudagent_daemon.metrics``
21
+ directly so the dependency graph stays explicit.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from typing import Callable
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Internal sample shapes
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _format_labels(labels: dict[str, str]) -> str:
40
+ """Render ``{k="v",k2="v2"}`` with Prometheus-safe escaping.
41
+
42
+ Per Prometheus exposition format: ``\\`` → ``\\\\``, ``"`` → ``\\"``,
43
+ newline → ``\\n``. Empty labels render as empty string (no braces).
44
+ """
45
+ if not labels:
46
+ return ""
47
+ parts = []
48
+ for k in sorted(labels):
49
+ v = str(labels[k])
50
+ v = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
51
+ parts.append(f'{k}="{v}"')
52
+ return "{" + ",".join(parts) + "}"
53
+
54
+
55
+ @dataclass
56
+ class _Counter:
57
+ """Monotonically-increasing 64-bit-ish int. Labels are optional."""
58
+
59
+ name: str
60
+ help: str
61
+ # samples[label_tuple] -> int value. label_tuple is a frozenset of
62
+ # (k, v) so identical labels with different insertion order collapse.
63
+ samples: dict[frozenset, int] = field(default_factory=dict)
64
+
65
+ def inc(self, n: int = 1, labels: dict[str, str] | None = None) -> None:
66
+ key = frozenset((labels or {}).items())
67
+ self.samples[key] = self.samples.get(key, 0) + n
68
+
69
+ def get(self, labels: dict[str, str] | None = None) -> int:
70
+ key = frozenset((labels or {}).items())
71
+ return self.samples.get(key, 0)
72
+
73
+ def render(self) -> list[str]:
74
+ out = [
75
+ f"# HELP {self.name} {self.help}",
76
+ f"# TYPE {self.name} counter",
77
+ ]
78
+ for key, val in sorted(
79
+ self.samples.items(), key=lambda kv: sorted(kv[0])
80
+ ):
81
+ labels = dict(key)
82
+ out.append(f"{self.name}{_format_labels(labels)} {val}")
83
+ if not self.samples:
84
+ # Emit a 0 line so scrapers see the series exists even when
85
+ # nothing has incremented yet.
86
+ out.append(f"{self.name} 0")
87
+ return out
88
+
89
+
90
+ @dataclass
91
+ class _Gauge:
92
+ """Float-valued sample; ``set`` overwrites, ``inc`` deltas.
93
+
94
+ Gauges are not labelled in this registry (we don't currently have a
95
+ use case that requires them; adding labels later is a strict
96
+ superset and would not break callers)."""
97
+
98
+ name: str
99
+ help: str
100
+ value: float = 0.0
101
+ # Optional resolver: if set, ``render()`` calls it to produce a
102
+ # fresh value at scrape time. Used for gauges that derive from
103
+ # external state we don't want to mirror in-memory (e.g.
104
+ # ``last_event_age_seconds`` on :class:`SSEClient`).
105
+ resolver: Callable[[], float] | None = None
106
+
107
+ def set(self, v: float) -> None:
108
+ self.value = float(v)
109
+
110
+ def inc(self, n: float = 1.0) -> None:
111
+ self.value += float(n)
112
+
113
+ def get(self) -> float:
114
+ if self.resolver is not None:
115
+ try:
116
+ return float(self.resolver())
117
+ except Exception: # noqa: BLE001
118
+ logger.debug(
119
+ "metrics: gauge %s resolver raised; falling back to %.3f",
120
+ self.name, self.value,
121
+ )
122
+ return self.value
123
+
124
+ def render(self) -> list[str]:
125
+ return [
126
+ f"# HELP {self.name} {self.help}",
127
+ f"# TYPE {self.name} gauge",
128
+ f"{self.name} {self.get()}",
129
+ ]
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Registry
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class MetricsRegistry:
138
+ """Process-local metric store.
139
+
140
+ The daemon constructs a single instance at boot (see
141
+ :func:`get_default`) and threads it into the components that need
142
+ to report (frame dispatcher, spawn queue, claude runner, SSE
143
+ client, main_loop). Tests can spin up a fresh registry without
144
+ polluting the default.
145
+ """
146
+
147
+ def __init__(self) -> None:
148
+ self._counters: dict[str, _Counter] = {}
149
+ self._gauges: dict[str, _Gauge] = {}
150
+ self._lock = asyncio.Lock()
151
+ self._boot_at = time.monotonic()
152
+
153
+ # --- registration --------------------------------------------------
154
+
155
+ def counter(self, name: str, help: str) -> _Counter:
156
+ c = self._counters.get(name)
157
+ if c is None:
158
+ c = _Counter(name=name, help=help)
159
+ self._counters[name] = c
160
+ return c
161
+
162
+ def gauge(
163
+ self,
164
+ name: str,
165
+ help: str,
166
+ *,
167
+ resolver: Callable[[], float] | None = None,
168
+ ) -> _Gauge:
169
+ g = self._gauges.get(name)
170
+ if g is None:
171
+ g = _Gauge(name=name, help=help, resolver=resolver)
172
+ self._gauges[name] = g
173
+ elif resolver is not None and g.resolver is None:
174
+ # Allow late binding of a resolver — the SSE client may be
175
+ # constructed after the registry on cold boot.
176
+ g.resolver = resolver
177
+ return g
178
+
179
+ # --- mutation helpers (concurrency-safe) ---------------------------
180
+
181
+ async def inc_counter(
182
+ self,
183
+ name: str,
184
+ n: int = 1,
185
+ labels: dict[str, str] | None = None,
186
+ ) -> None:
187
+ async with self._lock:
188
+ c = self._counters.get(name)
189
+ if c is None:
190
+ # Lazy register with a placeholder help string so a typo
191
+ # at call-site doesn't silently drop metrics.
192
+ c = self.counter(name, f"(auto-registered) {name}")
193
+ c.inc(n, labels)
194
+
195
+ async def set_gauge(self, name: str, value: float) -> None:
196
+ async with self._lock:
197
+ g = self._gauges.get(name)
198
+ if g is None:
199
+ g = self.gauge(name, f"(auto-registered) {name}")
200
+ g.set(value)
201
+
202
+ # --- introspection --------------------------------------------------
203
+
204
+ @property
205
+ def uptime_seconds(self) -> float:
206
+ return max(0.0, time.monotonic() - self._boot_at)
207
+
208
+ def snapshot(self) -> dict:
209
+ """Return a JSON-able snapshot for ``doctor`` and tests."""
210
+ out: dict = {
211
+ "uptime_seconds": self.uptime_seconds,
212
+ "counters": {},
213
+ "gauges": {},
214
+ }
215
+ for name, c in self._counters.items():
216
+ entries: list[dict] = []
217
+ for key, val in c.samples.items():
218
+ entries.append({"labels": dict(key), "value": val})
219
+ out["counters"][name] = entries
220
+ for name, g in self._gauges.items():
221
+ out["gauges"][name] = g.get()
222
+ return out
223
+
224
+ def render_prometheus_text(self) -> str:
225
+ """Render the full registry in Prometheus 0.0.4 text format."""
226
+ lines: list[str] = []
227
+ # Always emit uptime as a baseline series so scrapers can detect
228
+ # daemon restarts even before anything else increments.
229
+ uptime = _Gauge(
230
+ name="cloudagent_daemon_uptime_seconds",
231
+ help="Seconds since daemon process boot.",
232
+ value=self.uptime_seconds,
233
+ )
234
+ lines.extend(uptime.render())
235
+ for name in sorted(self._counters):
236
+ lines.extend(self._counters[name].render())
237
+ for name in sorted(self._gauges):
238
+ lines.extend(self._gauges[name].render())
239
+ return "\n".join(lines) + "\n"
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Module-level default registry
244
+ # ---------------------------------------------------------------------------
245
+
246
+ _DEFAULT_REGISTRY: MetricsRegistry | None = None
247
+
248
+
249
+ def get_default() -> MetricsRegistry:
250
+ """Return the process-wide default registry.
251
+
252
+ Created lazily on first access. Tests should pass a fresh
253
+ :class:`MetricsRegistry` into components instead of mutating the
254
+ default (which would leak between tests).
255
+ """
256
+ global _DEFAULT_REGISTRY
257
+ if _DEFAULT_REGISTRY is None:
258
+ _DEFAULT_REGISTRY = MetricsRegistry()
259
+ _register_standard_metrics(_DEFAULT_REGISTRY)
260
+ return _DEFAULT_REGISTRY
261
+
262
+
263
+ def reset_default_for_tests() -> None:
264
+ """Wipe the default registry — fixture cleanup hook only."""
265
+ global _DEFAULT_REGISTRY
266
+ _DEFAULT_REGISTRY = None
267
+
268
+
269
+ def _register_standard_metrics(reg: MetricsRegistry) -> None:
270
+ """Pre-declare the well-known names so the first scrape produces
271
+ a complete set of series (avoid Prometheus' "missing series at
272
+ scrape N+1" gotcha)."""
273
+ reg.counter(
274
+ "cloudagent_daemon_deliver_frames_total",
275
+ "Total deliver SSE frames accepted by the dispatcher.",
276
+ )
277
+ reg.counter(
278
+ "cloudagent_daemon_spawn_completed_total",
279
+ "Total claude wakes completed, labelled by terminal status.",
280
+ )
281
+ reg.counter(
282
+ "cloudagent_daemon_spawn_fallback_send_message_total",
283
+ "Times the daemon POSTed send_message on claude's behalf (1k #63 Layer B).",
284
+ )
285
+ reg.counter(
286
+ "cloudagent_daemon_sse_reconnects_total",
287
+ "Total SSE reconnects, including active-watchdog forced ones.",
288
+ )
289
+ reg.counter(
290
+ "cloudagent_daemon_sse_active_watchdog_fires_total",
291
+ "Times the active liveness watchdog forced a reconnect (Wave C #2).",
292
+ )
293
+ reg.gauge(
294
+ "cloudagent_daemon_outbox_pending",
295
+ "Current outbox entries pending a spawn (received-without-done).",
296
+ )
297
+ reg.gauge(
298
+ "cloudagent_daemon_outbox_stuck",
299
+ "Outbox entries past their per-priority threshold at last scan.",
300
+ )
301
+ reg.gauge(
302
+ "cloudagent_daemon_agents_bound",
303
+ "Agents bound to this host as of the last ready/assign event.",
304
+ )
305
+ reg.gauge(
306
+ "cloudagent_daemon_sse_last_event_age_seconds",
307
+ "Seconds since the last parsed SSE event arrived.",
308
+ )
309
+
310
+
311
+ __all__ = [
312
+ "MetricsRegistry",
313
+ "get_default",
314
+ "reset_default_for_tests",
315
+ ]
@@ -0,0 +1,149 @@
1
+ """Wave C #3 — minimal unix-socket HTTP server exposing ``/metrics``.
2
+
3
+ Why unix socket instead of TCP HTTP:
4
+ * No port allocation surprises on user machines (laptops behind
5
+ pf/firewall rules can't reliably open arbitrary listening ports).
6
+ * Permissioned by filesystem (``0o600``) — only the daemon-owning
7
+ user can scrape. No need for a separate auth scheme.
8
+ * Prometheus 2.x supports unix socket scraping natively
9
+ (``-target=unix:///path/to/sock``) and ``curl --unix-socket`` is
10
+ a one-liner for ad-hoc operator inspection.
11
+
12
+ Why we don't reuse ``bridge.sock``:
13
+ * That socket speaks line-delimited JSON to mcp_bridge subprocesses.
14
+ Mixing protocols invites bugs; a second socket is cheap.
15
+ * Decoupling lets us shut down ``bridge.sock`` independently if a
16
+ future refactor moves the mcp bridge into its own process tree.
17
+
18
+ Wire format (server response):
19
+
20
+ ::
21
+
22
+ HTTP/1.1 200 OK\r\n
23
+ Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n
24
+ Content-Length: <n>\r\n
25
+ Connection: close\r\n
26
+ \r\n
27
+ <prometheus text>
28
+
29
+ Request parsing is intentionally permissive: we ignore the path and
30
+ respond to ANY connection with the metrics body. This keeps the server
31
+ 3 dozen lines and avoids re-implementing HTTP. A caller that opens the
32
+ socket and immediately reads (e.g. ``curl --unix-socket sock http://x/``)
33
+ gets a valid HTTP response either way.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import logging
39
+ import os
40
+ from pathlib import Path
41
+
42
+ from cloudagent_daemon.metrics import MetricsRegistry
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Default location. The daemon's pid + state live in the same dir so
47
+ # operators inspecting one usually want the other.
48
+ DEFAULT_METRICS_SOCKET_PATH = (
49
+ Path.home() / ".cloudagent" / "daemon" / "metrics.sock"
50
+ )
51
+
52
+
53
+ class MetricsServer:
54
+ """Unix-socket HTTP-ish responder serving Prometheus text format."""
55
+
56
+ def __init__(
57
+ self,
58
+ registry: MetricsRegistry,
59
+ *,
60
+ socket_path: Path | None = None,
61
+ ) -> None:
62
+ self._registry = registry
63
+ self._socket_path = Path(socket_path or DEFAULT_METRICS_SOCKET_PATH)
64
+ self._server: asyncio.base_events.Server | None = None
65
+
66
+ @property
67
+ def socket_path(self) -> Path:
68
+ return self._socket_path
69
+
70
+ async def start(self) -> None:
71
+ if self._socket_path.exists():
72
+ # Stale socket from a crashed process.
73
+ try:
74
+ self._socket_path.unlink()
75
+ except OSError: # pragma: no cover — race with another daemon
76
+ logger.warning(
77
+ "metrics_server: could not unlink stale socket %s",
78
+ self._socket_path,
79
+ )
80
+ self._socket_path.parent.mkdir(parents=True, exist_ok=True)
81
+ self._server = await asyncio.start_unix_server(
82
+ self._handle_connection, path=str(self._socket_path)
83
+ )
84
+ try:
85
+ os.chmod(self._socket_path, 0o600)
86
+ except OSError: # pragma: no cover — exotic filesystems
87
+ logger.warning(
88
+ "metrics_server: chmod 0o600 failed on %s",
89
+ self._socket_path,
90
+ )
91
+
92
+ async def stop(self) -> None:
93
+ if self._server is not None:
94
+ self._server.close()
95
+ await self._server.wait_closed()
96
+ self._server = None
97
+ try:
98
+ if self._socket_path.exists():
99
+ self._socket_path.unlink()
100
+ except OSError: # pragma: no cover
101
+ pass
102
+
103
+ async def _handle_connection(
104
+ self,
105
+ reader: asyncio.StreamReader,
106
+ writer: asyncio.StreamWriter,
107
+ ) -> None:
108
+ try:
109
+ # Drain the request lines until \r\n\r\n. We don't actually
110
+ # parse anything; we just need to consume the request before
111
+ # writing the response so the client doesn't see a half-open
112
+ # write.
113
+ try:
114
+ while True:
115
+ line = await asyncio.wait_for(
116
+ reader.readuntil(b"\r\n"),
117
+ timeout=2.0,
118
+ )
119
+ if line in (b"\r\n", b""):
120
+ break
121
+ except asyncio.TimeoutError:
122
+ logger.debug("metrics_server: request read timeout — serving anyway")
123
+ except asyncio.IncompleteReadError:
124
+ pass # client hung up; that's fine, write response anyway
125
+
126
+ body = self._registry.render_prometheus_text().encode("utf-8")
127
+ response = (
128
+ b"HTTP/1.1 200 OK\r\n"
129
+ b"Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n"
130
+ b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n"
131
+ b"Connection: close\r\n"
132
+ b"\r\n"
133
+ ) + body
134
+ writer.write(response)
135
+ try:
136
+ await writer.drain()
137
+ except (ConnectionResetError, BrokenPipeError):
138
+ # Scraper hung up before we finished — common with
139
+ # ``curl --unix-socket`` short-lived connections.
140
+ pass
141
+ finally:
142
+ try:
143
+ writer.close()
144
+ await writer.wait_closed()
145
+ except Exception: # noqa: BLE001
146
+ pass
147
+
148
+
149
+ __all__ = ["MetricsServer", "DEFAULT_METRICS_SOCKET_PATH"]