Files
FabledSteward/plugins/prometheus/scraper.py
T
bvandeusenandClaude Opus 5.5 f7a856cb90
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped
feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.

A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.

The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:16:42 -05:00

64 lines
2.7 KiB
Python

# plugins/prometheus/scraper.py
"""Fetching one /metrics endpoint. Every failure comes back as one sentence
a page can show, never a stack trace."""
from __future__ import annotations
import httpx
from steward.core import prometheus
TIMEOUT_SECONDS = 10.0
# A /metrics page past this is not one to store a series of; reading stops.
MAX_BYTES = 20 * 1024 * 1024
ACCEPT = "text/plain;version=0.0.4;q=1,*/*;q=0.1"
class ScrapeError(Exception):
"""Why a scrape got nothing, said to the person."""
class TokenRefused(ScrapeError):
"""The endpoint refused the token. Asking again won't change that, and
Home Assistant, for one, bans an address after a few refusals."""
async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport: httpx.AsyncBaseTransport | None = None) -> dict[str, prometheus.Family]:
"""The endpoint's families. `transport` stands in for the network in tests."""
headers = {"Accept": ACCEPT}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls,
transport=transport, follow_redirects=False) as client:
async with client.stream("GET", url, headers=headers) as response:
if response.status_code in (401, 403):
raise TokenRefused(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
if response.status_code != 200:
raise ScrapeError(f"Answered {response.status_code} {response.reason_phrase}, not metrics.")
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > MAX_BYTES:
raise ScrapeError(f"The page is over {MAX_BYTES // (1024 * 1024)} MB; "
"is this a /metrics address?")
except ScrapeError:
raise
except httpx.TimeoutException:
raise ScrapeError(f"No answer within {TIMEOUT_SECONDS:.0f} s.") from None
except httpx.ConnectError as exc:
raise ScrapeError(f"Couldn't connect: {_reason(exc)}.") from None
except httpx.HTTPError as exc:
raise ScrapeError(f"The request failed: {_reason(exc)}.") from None
families = prometheus.parse(body.decode("utf-8", errors="replace"))
if not families:
raise ScrapeError("It answered, but with no metrics in Prometheus format.")
return families
def _reason(exc: Exception) -> str:
text = str(exc).strip() or type(exc).__name__
return text.rstrip(".")