feat(traefik): routers are graded against their own normal (#5741)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m3s

#5741 "Traefik routers are graded against their own normal", milestone 513.

- A router's 4xx share and p95 learn their normal (learn_normal in the
  plugin's setup). Once a router has 5 days of history, a reading over the
  fixed cutoff but inside its normal is healthy, and the healthy table says
  it is usual for that router. One out of its normal is a warning that says
  what is usual, and a metric anomaly.
- The 5xx share is a failure: fixed cutoff always, whatever the history or
  traffic (System 182's ruling).
- A 4xx share over fewer requests than Settings -> Thresholds' new "Fewest
  requests to judge 4xx" (30 per 15 minutes) isn't graded on the page, and
  isn't judged for anomalies.
- Core: learn_normal(gate=(metric, least)) judges a key only while its
  resource's gate metric averages at least `least`; `least` may be a
  function, so it follows a setting. learn_normal(since=...) learns only
  from history after a moment: Traefik latency learns from #5755's
  per-scrape figures only.
- The Traefik page lists the routers' metric anomalies of the last 30 days
  (_anomaly_card.html). The dashboard widget keeps its why to problems.
- Thresholds and docs say when the cutoffs give way to a normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:48:34 -04:00
co-authored by Claude Opus 5.5
parent 98a0ced5eb
commit 9f14620271
12 changed files with 321 additions and 46 deletions
+62
View File
@@ -271,3 +271,65 @@ def test_cleanup_prunes_reviewed_anomalies_and_never_one_to_review(app, src):
)).scalars()}
assert asyncio.run(_go()) == {kept}
def test_a_gated_metric_is_judged_only_while_its_gate_is_open(app, src):
"""A router's 4xx share means nothing over a handful of requests (#5741):
learned with gate=("req", 1.0), it is judged only while the resource's
own "req" averages at least 1 over the window."""
from steward.core import norms
from steward.core.anomalies import WINDOW
from steward.models.metrics import PluginMetric, PluginMetricLatest
norms.learn_normal(src, METRIC, 5, gate=("req", 1.0))
res = _res("gated")
quiet, busy = NOW, NOW + timedelta(hours=2)
async def _req(s, until, value):
n = int(WINDOW.total_seconds() // 60)
s.add_all([PluginMetric(source_module=src, resource_name=res, metric_name="req",
value=value, recorded_at=until - timedelta(minutes=m)) for m in range(n)])
await s.merge(PluginMetricLatest(source_module=src, resource_name=res, metric_name="req",
value=value, recorded_at=until))
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await _req(s, quiet, 0.2)
await _req(s, busy, 5.0)
return await _steps(app, src, res, [(quiet, 60.0), (busy, 60.0)])
rows, spans = asyncio.run(_go())
(a,) = rows # only the busy window opened one
assert spans[a.id][0].started_at == busy - WINDOW
def test_a_metric_learns_only_from_its_since(app, src):
"""A metric whose meaning changed (Traefik latency, #5755) learns only
from history after `since`: seven days held 90, but only the last three
are after it, under the five a normal needs."""
from steward.core import norms
from steward.core.norms import norms_at, rebuild_norms
from steward.models.metrics import PluginMetricHourly
res = _res("since")
rows = [PluginMetricHourly(source_module=src, resource_name=res, metric_name=METRIC,
bucket=NOW.replace(hour=0) - timedelta(days=d) + timedelta(hours=h),
value_avg=90.0, value_max=90.0, value_p95=90.0, sample_count=60)
for d in range(1, 8) for h in range(24)]
async def _go(since):
norms.learn_normal(src, METRIC, 5, since=since)
async with app.db_sessionmaker() as s:
async with s.begin():
await rebuild_norms(s, NOW)
got = await norms_at(s, [(src, res, METRIC)], NOW.replace(hour=14))
return got.get((src, res, METRIC))
async def _both():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add_all(rows)
return await _go(None), await _go(NOW.replace(hour=0) - timedelta(days=3))
learned, held_back = asyncio.run(_both())
assert learned is not None and learned.usual_hold == 90.0
assert held_back is None