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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|