fix(traefik): grade routers on their last 15 minutes; the Config tile reads Traefik's reload time (#5355)
The operator: problem cards "can disappear so quickly". Each router was graded on its latest scrape, so a 4xx share near the 1% cutoff crossed it on one scrape and fell back on the next. recent_window reads every router's last 15 minutes in one query, the error shares weighted by traffic; grade_routers uses it on the page and the dashboard widget, and the why says "over the last 15 min". traefik_config_last_reload_success is the Unix time of the last good reload, not a 0/1 flag, so the tile said "Reload failed" on every scrape. traefik_005 stores it as config_last_reload_at, and the tile reads "Config reloaded 3m ago". The router index also carries the 4xx share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -108,3 +108,34 @@ def test_router_history_reads_every_router_in_one_query_bucketed(app):
|
||||
assert got[a][0].latency_p95_ms == 20.0
|
||||
assert [r.request_rate for r in got[b]] == [4.0]
|
||||
assert got[idle] == [] # its only row is before the range
|
||||
|
||||
|
||||
def test_recent_window_weights_shares_by_traffic_and_keeps_to_the_window(app):
|
||||
"""#5214: a router is graded on its last 15 minutes. An idle scrape's
|
||||
100% 4xx counts for its trickle of traffic, not as a third of the
|
||||
average; a row older than the window is not read."""
|
||||
from plugins.traefik.models import TraefikMetric
|
||||
from plugins.traefik.routes import recent_window
|
||||
|
||||
now = datetime(2097, 3, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=uuid.uuid4().int % 10_000)
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
busy, empty = f"busy-{tag}@docker", f"empty-{tag}@docker"
|
||||
|
||||
def row(name, ago, rate, e4, p95=10.0):
|
||||
return TraefikMetric(router_name=name, scraped_at=now - timedelta(minutes=ago), request_rate=rate,
|
||||
error_rate_4xx_pct=e4, error_rate_5xx_pct=0.0, latency_p50_ms=1.0,
|
||||
latency_p95_ms=p95, latency_p99_ms=3.0, response_bytes_rate=0.0)
|
||||
|
||||
async def go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add_all([row(busy, 1, 9.9, 0.0, 10.0), row(busy, 2, 0.1, 100.0, 30.0),
|
||||
row(busy, 30, 50.0, 100.0), # outside the window
|
||||
row(empty, 30, 1.0, 100.0)])
|
||||
async with s.begin():
|
||||
return await recent_window(s, [busy, empty], now)
|
||||
|
||||
got = asyncio.run(go())
|
||||
assert set(got) == {busy} # nothing in empty's window
|
||||
assert got[busy].error_rate_4xx_pct == pytest.approx(1.0) # 0.1 × 100 over 10 req/s
|
||||
assert got[busy].latency_p95_ms == pytest.approx(20.0)
|
||||
|
||||
@@ -246,3 +246,42 @@ async def test_the_request_chart_says_its_figures_before_the_chart(app):
|
||||
assert "Requests/s (all routers)" in page and "max: 100" not in page
|
||||
assert "couldn't be drawn" in page # a failure says why in place of the chart
|
||||
assert "No Traefik requests recorded in the last 6h." in empty
|
||||
|
||||
|
||||
def test_routers_are_graded_on_their_recent_window_not_one_scrape():
|
||||
"""#5214: a 4xx share near the cutoff crossed it on one scrape and fell
|
||||
back on the next, so problem cards came and went every poll."""
|
||||
from plugins.traefik.routes import STATE_WINDOW_WORDS, grade_routers
|
||||
spiky, quiet = _latest(e4=1.5), _latest(e4=1.5)
|
||||
routers = [{"name": "spiky", "latest": spiky}, {"name": "quiet", "latest": quiet}]
|
||||
# spiky's last 15 min average well under the cutoff; quiet has no window rows.
|
||||
grade_routers(routers, {"spiky": NS(error_rate_4xx_pct=0.4, error_rate_5xx_pct=0.0, latency_p95_ms=20.0)}, TH)
|
||||
assert routers[0]["state"] == "ok"
|
||||
assert routers[1]["state"] == "warn" and not routers[1]["why"].endswith(STATE_WINDOW_WORDS)
|
||||
held = [{"name": "held", "latest": _latest()}] # clean now, bad over the window
|
||||
grade_routers(held, {"held": NS(error_rate_4xx_pct=3.0, error_rate_5xx_pct=0.0, latency_p95_ms=20.0)}, TH)
|
||||
assert held[0]["state"] == "warn"
|
||||
assert held[0]["why"] == "3.0% of responses are 4xx over the last 15 min"
|
||||
|
||||
|
||||
def test_the_last_reload_gauge_is_a_moment_not_a_flag():
|
||||
"""#5355: traefik_config_last_reload_success is a Unix time; read as a
|
||||
0/1 flag it said "Reload failed" on every scrape."""
|
||||
from plugins.traefik.scraper import compute_global_stats
|
||||
at = compute_global_stats({"traefik_config_last_reload_success": {frozenset(): 1791460000.0},
|
||||
"traefik_config_reloads_total": {frozenset(): 343.0}}, None)
|
||||
assert at["config_last_reload_at"] == datetime.fromtimestamp(1791460000.0, timezone.utc)
|
||||
assert at["config_reloads_total"] == 343.0
|
||||
never = compute_global_stats({"traefik_config_last_reload_success": {frozenset(): 0.0}}, None)
|
||||
assert never["config_last_reload_at"] is None
|
||||
|
||||
|
||||
async def test_the_config_tile_says_when_it_last_reloaded(app):
|
||||
stat = NS(config_last_reload_at=datetime.now(timezone.utc) - timedelta(minutes=3),
|
||||
config_reloads_total=343.0, open_conns_total=None, process_memory_bytes=None)
|
||||
async with app.test_request_context("/plugins/traefik/rows"):
|
||||
page = await render_plugin_template(app, "traefik", "traefik/rows.html", problems=[], healthy=[],
|
||||
gone=[], global_stat=stat, certs=[], old_certs=[], range_key="24h",
|
||||
error_4xx=1.0, error_5xx=0.1)
|
||||
assert "Config reloaded" in page and "3m ago" in page and "343 reloads" in page
|
||||
assert "Reload failed" not in page
|
||||
|
||||
Reference in New Issue
Block a user