feat: monitors, UniFi and Traefik keep collecting through a storage stall (#5995)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m38s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m11s

The same Holding as the SNMP poll, for the rest of Steward's own
collectors:

- Monitors: the list is read within 10 s or the last one read stands in;
  a start the database doesn't take is kept in memory, so a monitor isn't
  checked twice; each result is held and stored at the time it was
  checked. A stall shows on the monitors as the checks it saw, not a gap.
- UniFi and Traefik: each poll's writes are held (hold_write) and run
  oldest first in one transaction; every reading is stamped with its scrape.
- A held round the database refuses (as opposed to can't be reached) is
  stored alone and let go, so it can't hold up every round after it.

The Prometheus scrape is left as it is: its one target, Fabled Archives,
keeps its own database on the same NFS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 09:41:14 -04:00
co-authored by Claude Opus 5.5
parent 41fc8073db
commit 2df8431de9
6 changed files with 557 additions and 388 deletions
+73 -70
View File
@@ -75,6 +75,7 @@ async def _do_scrape(app: "Quart") -> None:
fetch_router_domains,
)
from steward.core.alerts import MetricBatch
from steward.core.holding import hold_write
metrics_url: str = app.config["PLUGINS"]["traefik"]["metrics_url"]
@@ -106,81 +107,83 @@ async def _do_scrape(app: "Quart") -> None:
scraped_at = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
batch = MetricBatch()
# ── Per-service metrics ───────────────────────────────────────────
for router_name, metrics in router_metrics.items():
row = TraefikMetric(
router_name=router_name,
scraped_at=scraped_at,
request_rate=metrics["request_rate"],
error_rate_4xx_pct=metrics["error_rate_4xx_pct"],
error_rate_5xx_pct=metrics["error_rate_5xx_pct"],
latency_p50_ms=metrics["latency_p50_ms"],
latency_p95_ms=metrics["latency_p95_ms"],
latency_p99_ms=metrics["latency_p99_ms"],
response_bytes_rate=metrics["response_bytes_rate"],
)
session.add(row)
async def write(session) -> None:
batch = MetricBatch()
# ── Per-service metrics ───────────────────────────────────────────
for router_name, metrics in router_metrics.items():
row = TraefikMetric(
router_name=router_name,
scraped_at=scraped_at,
request_rate=metrics["request_rate"],
error_rate_4xx_pct=metrics["error_rate_4xx_pct"],
error_rate_5xx_pct=metrics["error_rate_5xx_pct"],
latency_p50_ms=metrics["latency_p50_ms"],
latency_p95_ms=metrics["latency_p95_ms"],
latency_p99_ms=metrics["latency_p99_ms"],
response_bytes_rate=metrics["response_bytes_rate"],
)
session.add(row)
for metric_name, value in [
("request_rate", metrics["request_rate"]),
("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]),
("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]),
("latency_p50_ms", metrics["latency_p50_ms"]),
("latency_p95_ms", metrics["latency_p95_ms"]),
("latency_p99_ms", metrics["latency_p99_ms"]),
("response_bytes_rate", metrics["response_bytes_rate"]),
]:
if value is None: # no responses since the last scrape: no latency
continue
# Stamped with the scrape, so a router's latest figures
# (routes.latest_per_router) all carry the same moment.
batch.add(
source_module="traefik",
resource_name=router_name,
metric_name=metric_name,
value=value,
recorded_at=scraped_at,
)
# ── 4xx and 5xx responses by code (#5711) ─────────────────────────
for router_name, codes in status_codes.items():
for code, count in codes.items():
session.add(TraefikStatusCode(router_name=router_name, scraped_at=scraped_at,
code=code, count=count))
# ── Global stats ──────────────────────────────────────────────────
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
# ── TLS certs (upsert by serial) ──────────────────────────────────
for cert in cert_list:
existing = await session.get(TraefikCert, cert["serial"])
if existing:
existing.cn = cert["cn"]
existing.sans = cert["sans"]
existing.not_after = cert["not_after"]
existing.scraped_at = scraped_at
else:
session.add(TraefikCert(
serial=cert["serial"],
cn=cert["cn"],
sans=cert["sans"],
not_after=cert["not_after"],
scraped_at=scraped_at,
))
# Emit cert_expiry_days for alert rules
days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0
for metric_name, value in [
("request_rate", metrics["request_rate"]),
("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]),
("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]),
("latency_p50_ms", metrics["latency_p50_ms"]),
("latency_p95_ms", metrics["latency_p95_ms"]),
("latency_p99_ms", metrics["latency_p99_ms"]),
("response_bytes_rate", metrics["response_bytes_rate"]),
]:
if value is None: # no responses since the last scrape: no latency
continue
# Stamped with the scrape, so a router's latest figures
# (routes.latest_per_router) all carry the same moment.
batch.add(
source_module="traefik",
resource_name=cert["cn"] or cert["serial"],
metric_name="cert_expiry_days",
value=max(0.0, days_remaining),
resource_name=router_name,
metric_name=metric_name,
value=value,
recorded_at=scraped_at,
)
await batch.record(session)
# ── 4xx and 5xx responses by code (#5711) ─────────────────────────
for router_name, codes in status_codes.items():
for code, count in codes.items():
session.add(TraefikStatusCode(router_name=router_name, scraped_at=scraped_at,
code=code, count=count))
# ── Global stats ──────────────────────────────────────────────────
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
# ── TLS certs (upsert by serial) ──────────────────────────────────
for cert in cert_list:
existing = await session.get(TraefikCert, cert["serial"])
if existing:
existing.cn = cert["cn"]
existing.sans = cert["sans"]
existing.not_after = cert["not_after"]
existing.scraped_at = scraped_at
else:
session.add(TraefikCert(
serial=cert["serial"],
cn=cert["cn"],
sans=cert["sans"],
not_after=cert["not_after"],
scraped_at=scraped_at,
))
# Emit cert_expiry_days for alert rules
days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0
batch.add(
source_module="traefik",
resource_name=cert["cn"] or cert["serial"],
metric_name="cert_expiry_days",
value=max(0.0, days_remaining),
recorded_at=scraped_at,
)
await batch.record(session)
await hold_write(app, "Traefik scrape", write)
logger.debug(
"Traefik scrape: %d router(s), %d cert(s)",