fix(traefik): the newest scrape per router comes from plugin_metric_latest (#5355)
The phase log on the live instance: 'traefik rows: slow 7.4s (latest=7.04s history=0.11s window=0.04s ...)'. latest_per_router walked traefik_metrics' index one router at a time; each step lands on the router's oldest entries, which retention deletes every minute (likely dead ones). The scraper already records every router figure through MetricBatch, which keeps one row per router and figure in plugin_metric_latest (#5208's table, read as latest_by_resource does); latest_per_router reads that now, and the router figures carry the scrape's own time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-20
@@ -4,7 +4,7 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from quart import Blueprint, current_app, render_template, request
|
from quart import Blueprint, current_app, render_template, request
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from steward.auth.middleware import require_role
|
|||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
|
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
|
||||||
from steward.core.formatting import cert_left, status_class
|
from steward.core.formatting import cert_left, status_class
|
||||||
|
from steward.models.metrics import PluginMetricLatest
|
||||||
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
||||||
from .scheduler import router_domains
|
from .scheduler import router_domains
|
||||||
from .scraper import cert_names, covers
|
from .scraper import cert_names, covers
|
||||||
@@ -140,27 +141,30 @@ def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
|
|||||||
return problems, healthy
|
return problems, healthy
|
||||||
|
|
||||||
|
|
||||||
# Newest row per router. Postgres has no skip scan, so DISTINCT or max() GROUP
|
# The figures the scraper records for every router (scheduler._do_scrape).
|
||||||
# BY reads every row in retention; this walks the (router_name, scraped_at)
|
ROUTER_METRICS = ("request_rate", "error_rate_4xx_pct", "error_rate_5xx_pct", "latency_p50_ms",
|
||||||
# index one router at a time instead: a step per router, not per row.
|
"latency_p95_ms", "latency_p99_ms", "response_bytes_rate")
|
||||||
_LATEST_PER_ROUTER = text("""
|
|
||||||
WITH RECURSIVE names AS (
|
|
||||||
SELECT min(router_name) AS name FROM traefik_metrics
|
|
||||||
UNION ALL
|
|
||||||
SELECT (SELECT min(router_name) FROM traefik_metrics WHERE router_name > names.name)
|
|
||||||
FROM names WHERE names.name IS NOT NULL
|
|
||||||
)
|
|
||||||
SELECT m.* FROM names
|
|
||||||
JOIN LATERAL (
|
|
||||||
SELECT * FROM traefik_metrics WHERE router_name = names.name
|
|
||||||
ORDER BY scraped_at DESC LIMIT 1
|
|
||||||
) m ON true
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
async def latest_per_router(db) -> list:
|
async def latest_per_router(db) -> list[SimpleNamespace]:
|
||||||
return list((await db.execute(
|
"""Each router's newest scrape, read from plugin_metric_latest: the scraper
|
||||||
select(TraefikMetric).from_statement(_LATEST_PER_ROUTER))).scalars().all())
|
records every figure through MetricBatch, which keeps one row per router
|
||||||
|
and figure there (latest_by_resource's table, #5208). A walk of
|
||||||
|
traefik_metrics' index, one step per router, took 7 s on the live
|
||||||
|
instance (#5355); each step lands on the router's oldest entries, which
|
||||||
|
retention deletes every minute, so it likely waded through dead ones.
|
||||||
|
|
||||||
|
Each router reads as one scrape, with the attributes a TraefikMetric row
|
||||||
|
has; certificates, recorded under the same source, are left out."""
|
||||||
|
rows = (await db.execute(select(PluginMetricLatest).where(
|
||||||
|
PluginMetricLatest.source_module == "traefik",
|
||||||
|
PluginMetricLatest.metric_name.in_(ROUTER_METRICS)))).scalars()
|
||||||
|
by_router: dict[str, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_router.setdefault(r.resource_name, {})[r.metric_name] = r
|
||||||
|
return [SimpleNamespace(router_name=name, scraped_at=max(m.recorded_at for m in figures.values()),
|
||||||
|
**{k: figures[k].value if k in figures else 0.0 for k in ROUTER_METRICS})
|
||||||
|
for name, figures in by_router.items() if "request_rate" in figures]
|
||||||
|
|
||||||
|
|
||||||
def current_certs(certs, scrape_seconds: int) -> tuple[list, list]:
|
def current_certs(certs, scrape_seconds: int) -> tuple[list, list]:
|
||||||
|
|||||||
@@ -115,11 +115,14 @@ async def _do_scrape(app: "Quart") -> None:
|
|||||||
("latency_p99_ms", metrics["latency_p99_ms"]),
|
("latency_p99_ms", metrics["latency_p99_ms"]),
|
||||||
("response_bytes_rate", metrics["response_bytes_rate"]),
|
("response_bytes_rate", metrics["response_bytes_rate"]),
|
||||||
]:
|
]:
|
||||||
|
# Stamped with the scrape, so a router's latest figures
|
||||||
|
# (routes.latest_per_router) all carry the same moment.
|
||||||
batch.add(
|
batch.add(
|
||||||
source_module="traefik",
|
source_module="traefik",
|
||||||
resource_name=router_name,
|
resource_name=router_name,
|
||||||
metric_name=metric_name,
|
metric_name=metric_name,
|
||||||
value=value,
|
value=value,
|
||||||
|
recorded_at=scraped_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Global stats ──────────────────────────────────────────────────
|
# ── Global stats ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,29 +21,42 @@ def app():
|
|||||||
return create_app(testing=False)
|
return create_app(testing=False)
|
||||||
|
|
||||||
|
|
||||||
def test_each_router_reads_as_its_newest_row(app):
|
def test_each_router_reads_as_its_newest_scrape(app):
|
||||||
from plugins.traefik.models import TraefikMetric
|
"""#5355: the newest scrape per router comes from plugin_metric_latest,
|
||||||
from plugins.traefik.routes import latest_per_router
|
which the scraper's MetricBatch keeps, not from a walk of traefik_metrics
|
||||||
|
(7 s on the live instance). A backlog arriving late doesn't move it back,
|
||||||
|
and a certificate's figure under the same source is not a router."""
|
||||||
|
from steward.core.alerts import MetricBatch
|
||||||
|
from plugins.traefik.routes import ROUTER_METRICS, latest_per_router
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
tag = uuid.uuid4().hex[:8]
|
tag = uuid.uuid4().hex[:8]
|
||||||
a, b = f"a-{tag}@docker", f"b-{tag}@docker"
|
a, b, cert = f"a-{tag}@docker", f"b-{tag}@docker", f"cert-{tag}.example"
|
||||||
|
|
||||||
def row(name, ago, rate):
|
def scrape(batch, name, ago, rate):
|
||||||
return TraefikMetric(router_name=name, scraped_at=now - timedelta(minutes=ago), request_rate=rate,
|
for m in ROUTER_METRICS:
|
||||||
error_rate_4xx_pct=0.0, error_rate_5xx_pct=0.0, latency_p50_ms=1.0,
|
batch.add("traefik", name, m, rate if m == "request_rate" else 1.0,
|
||||||
latency_p95_ms=2.0, latency_p99_ms=3.0, response_bytes_rate=0.0)
|
recorded_at=now - timedelta(minutes=ago))
|
||||||
|
|
||||||
async def go():
|
async def go():
|
||||||
async with app.db_sessionmaker() as s:
|
async with app.db_sessionmaker() as s:
|
||||||
|
for name, ago, rate in [(a, 3, 1.0), (a, 1, 2.0), (b, 5, 4.0), (a, 2, 3.0)]:
|
||||||
|
async with s.begin():
|
||||||
|
batch = MetricBatch()
|
||||||
|
scrape(batch, name, ago, rate)
|
||||||
|
await batch.record(s)
|
||||||
async with s.begin():
|
async with s.begin():
|
||||||
s.add_all([row(a, 3, 1.0), row(a, 1, 2.0), row(a, 2, 3.0), row(b, 5, 4.0)])
|
batch = MetricBatch()
|
||||||
|
batch.add("traefik", cert, "cert_expiry_days", 30.0, recorded_at=now)
|
||||||
|
await batch.record(s)
|
||||||
async with s.begin():
|
async with s.begin():
|
||||||
return {m.router_name: m for m in await latest_per_router(s)}
|
return {m.router_name: m for m in await latest_per_router(s)}
|
||||||
|
|
||||||
got = asyncio.run(go())
|
got = asyncio.run(go())
|
||||||
assert got[a].request_rate == 2.0 and got[a].scraped_at == now - timedelta(minutes=1)
|
assert got[a].request_rate == 2.0 and got[a].scraped_at == now - timedelta(minutes=1)
|
||||||
|
assert got[a].latency_p95_ms == 1.0 and got[a].response_bytes_rate == 1.0
|
||||||
assert got[b].request_rate == 4.0
|
assert got[b].request_rate == 4.0
|
||||||
|
assert cert not in got
|
||||||
|
|
||||||
|
|
||||||
def test_the_request_chart_sums_routers_and_weights_the_5xx_share(app):
|
def test_the_request_chart_sums_routers_and_weights_the_5xx_share(app):
|
||||||
|
|||||||
Reference in New Issue
Block a user