feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:04:39 -04:00
co-authored by Claude Opus 5.5
parent 31a6cf09a9
commit 685850e681
7 changed files with 307 additions and 13 deletions
+5
View File
@@ -84,6 +84,11 @@ The full Traefik page at `/plugins/traefik/` reads top to bottom, for the time r
1. **The verdict.** It names the routers that need a look, says why, and says when the problem started ("api, 3.0% of responses are 5xx, started 2h ago"). When a router has been a problem for the whole range, it says so instead. Otherwise it reads "All N routers healthy".
2. **The request chart.** Requests per second across all routers and the share answered 5xx, the same chart as the dashboard widget.
3. **A card for each router that needs a look:** why, since when, the 4xx and 5xx status codes it answered in the last 15 minutes, most first ("404 ×1,812, 502 ×40"), and its request rate, bandwidth, p95/p99 latency and 4xx/5xx shares, each with its history. The code is what separates scanners probing for pages (404), a login wall (401/403) and an app that isn't answering (502/503/504). Steward records codes from the version that added them on; until then a card says they aren't recorded yet.
Each card then says what its problem usually means and what to do (`plugins/traefik/meaning.py`, #5712):
- **Server errors (5xx)** are almost always real. Mostly 500: the app is failing requests. Mostly 502/503/504: Traefik couldn't reach the app, or it timed out.
- **Client errors (4xx)** are usually traffic rather than a fault. Mostly 404: scanners and bots. 401/403: a login wall. 429: rate limiting. Mostly 499 is the exception: clients giving up on a slow app.
- **Slow responses** compare p95 with p50. When p95 is 4× p50 or more, a few requests are slow (one endpoint or a heavy page). When p50 is close behind, the whole app is slow.
- **Next:** a fault links to the swarm service behind the router (its tasks and logs) when Steward can match the name ("codex@swarm" → `codex_app`, "radarr@swarm" → `arr_radarr`), otherwise to Docker → Swarm. Traffic links to Settings → Thresholds.
4. **Traefik's own figures:** when its config last reloaded, open connections, memory.
5. **The healthy routers,** folded into one table, then the routers Traefik no longer reports, then TLS certificates by soonest expiry, then Traffic Origin when the access log is on.
+3 -12
View File
@@ -385,18 +385,9 @@ def summary(job) -> str:
def docker_models():
"""The loaded Docker plugin's models module, or None when it isn't loaded.
Reached through the plugin manager: importing plugins.docker.models by its
path would define its tables a second time, and the app would refuse it."""
import importlib
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("docker")
if loaded is None:
return None
try:
return importlib.import_module(f"{loaded.__name__}.models")
except ImportError:
return None
"""The loaded Docker plugin's models module, or None when it isn't loaded."""
from steward.core.plugin_manager import loaded_plugin_submodule
return loaded_plugin_submodule("docker", "models")
async def _swarm(session) -> tuple:
+115
View File
@@ -0,0 +1,115 @@
"""What a router problem means, whether it is likely real, and what to do (#5712).
Pure: a problem card hands in its grade, its why, the status codes it saw and
its latest figures, and gets back the words for it. The wording lives here, in
one place, so other views of a router (its normal, an alert) can say the same.
"""
from __future__ import annotations
# A wide gap between p95 and p50 means a few slow requests, not a slow app.
SLOW_TAIL_RATIO = 4.0
KINDS = {
"5xx": "Server errors",
"4xx": "Client errors",
"slow": "Slow responses",
}
def problem_kind(state: str, why: str | None) -> str | None:
"""The kind of a graded router's problem: "5xx", "slow", "4xx", or None
for a healthy router. routes.router_state grades 5xx as crit, and words a
warning as either a p95 or a 4xx share."""
if state == "crit":
return "5xx"
if state == "warn":
return "slow" if (why or "").startswith("p95") else "4xx"
return None
def _top(codes: list[tuple[str, int]], first: str) -> str | None:
"""The most-answered code of one class ("4" or "5"); codes come most first."""
return next((c for c, _n in codes if c[:1] == first), None)
def _share(codes: list[tuple[str, int]], first: str, wanted: set[str]) -> float:
total = sum(n for c, n in codes if c[:1] == first)
return sum(n for c, n in codes if c in wanted) / total if total else 0.0
def explain(kind: str, codes: list[tuple[str, int]], p50_ms: float | None = None,
p95_ms: float | None = None, normed: bool = False) -> dict:
"""{"label", "meaning", "likely", "action"} for a problem card:
`meaning` what the codes or figures usually point to, `likely` whether
it is usually a fault, `action` "act" (look at the app) or "tune" (the
cutoff may be wrong for this router). `normed` says the router was graded
against its own normal, so the problem is a departure from its usual."""
if kind == "5xx":
top = _top(codes, "5")
if top == "500":
meaning = "Mostly 500: the app is failing requests. Its logs say why."
elif top in ("502", "503", "504"):
meaning = (f"Mostly {top}: Traefik couldn't reach the app, or it timed out. "
"Check that its container is up and healthy.")
elif top:
meaning = f"Mostly {top}: the app is answering with server errors. Its logs say why."
else:
meaning = "The app is answering with server errors. Its logs say why."
return {"label": KINDS[kind], "meaning": meaning,
"likely": "Almost always a real fault.", "action": "act"}
if kind == "4xx":
top = _top(codes, "4")
if top == "404":
meaning = ("Mostly 404: usually scanners and bots probing for paths, or links to "
"pages that moved. Usually harmless.")
elif top in ("401", "403"):
meaning = (f"Mostly {top}: a login wall doing its job, refusing requests without "
"a session. A problem only if it's you being refused.")
elif top == "429":
meaning = "Mostly 429: rate limiting, telling clients to slow down."
elif top == "499":
meaning = ("Mostly 499: clients giving up before the app answered, "
"often because the app is slow.")
elif top:
meaning = f"Mostly {top}: requests the app turned away as not allowed or not understood."
else:
meaning = "Requests the app turned away: often bots, scanners or a login wall."
# A slow app behind the 499s is a fault; a login wall or scanners are not.
if top == "499" and _share(codes, "4", {"499"}) >= 0.5:
return {"label": KINDS[kind], "meaning": meaning,
"likely": "Often a slow app rather than the clients.", "action": "act"}
likely = ("Above its usual level for this hour, so something changed, "
"but 4xx is still usually traffic rather than a fault." if normed
else "Usually traffic rather than a fault: worth tuning, not fixing.")
return {"label": KINDS[kind], "meaning": meaning, "likely": likely, "action": "tune"}
# slow
if p50_ms and p95_ms and p95_ms >= SLOW_TAIL_RATIO * p50_ms:
meaning = (f"p95 is {p95_ms / p50_ms:.0f}× p50 ({p50_ms:.0f} ms): a few slow requests, "
"likely one endpoint or a heavy page, not the whole app.")
elif p50_ms and p95_ms:
meaning = (f"p50 is close behind at {p50_ms:.0f} ms: the whole app is slow, "
"not one endpoint.")
else:
meaning = "The app is slow overall, or one endpoint is."
likely = ("Slower than usual for this hour." if normed
else "Real if users feel it; some apps are simply slow.")
return {"label": KINDS["slow"], "meaning": meaning, "likely": likely, "action": "act"}
def swarm_service(router: str, services: list[str]) -> str | None:
"""The swarm service a router fronts, by name: "radarr@swarm" is served
by "arr_radarr", "codex@swarm" by "codex_app" (its stack's app), and
"fabled-searxng@swarm" by "fabled_search_searxng" (its last word). None
when no single service matches, or the router isn't a swarm one."""
base, _, provider = router.partition("@")
if provider not in ("swarm", "docker"):
return None
for name in dict.fromkeys((base, base.rsplit("-", 1)[-1])):
for match in ([s for s in services if s == name],
[s for s in services if s.endswith("_" + name)],
[s for s in services if s == f"{name}_app"]):
if len(match) == 1:
return match[0]
return None
+27
View File
@@ -16,6 +16,7 @@ from steward.core.formatting import cert_left, status_class
from steward.core.norms import norms_at
from steward.core.settings import floor_of
from steward.models.metrics import PluginMetricLatest
from . import meaning
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode
from .scheduler import router_domains
from .scraper import cert_names, covers
@@ -183,6 +184,30 @@ def grade_routers(routers: list[dict], window: dict, thresholds: dict, norms: di
reading, own = window.get(r["name"]), norms.get(r["name"])
r["state"], r["why"] = (router_state(reading, thresholds, STATE_WINDOW_WORDS, own) if reading
else router_state(r["latest"], thresholds, norms=own))
r["kind"] = meaning.problem_kind(r["state"], r["why"])
r["normed"] = bool(own) and {"slow": "latency_p95_ms", "4xx": "error_rate_4xx_pct"}.get(
r["kind"], "") in own
async def swarm_service_names(db) -> list[str]:
"""Every swarm service the Docker plugin has stored; [] without it."""
from steward.core.plugin_manager import loaded_plugin_submodule
models = loaded_plugin_submodule("docker", "models")
if models is None:
return []
return list((await db.execute(select(models.DockerSwarmService.service_name).distinct())).scalars())
def explain_problems(problems: list[dict], services: list[str]) -> None:
"""Give each problem card what it means and its one next step (#5712):
`meaning` (meaning.explain's) and `service`, the swarm service behind
the router when Steward can tell which."""
for r in problems:
r["meaning"] = meaning.explain(r["kind"], r.get("codes", []),
getattr(r["latest"], "latency_p50_ms", None),
getattr(r["latest"], "latency_p95_ms", None),
r.get("normed", False))
r["service"] = meaning.swarm_service(r["name"], services)
# A router missing from this many scrapes in a row is no longer reported:
@@ -362,6 +387,7 @@ async def rows():
# old, a router with none listed may simply predate the recording.
first_code = await db.scalar(select(func.min(TraefikStatusCode.scraped_at)))
codes_known = first_code is not None and first_code <= now - STATE_WINDOW
services = await swarm_service_names(db) if any(r["state"] != "ok" for r in current) else []
phases.mark("codes")
# Latest global stat (always most recent, not range-filtered)
@@ -400,6 +426,7 @@ async def rows():
certs = [cert_row(c) for c in served]
old_certs = [cert_row(c) for c in old]
problems, healthy = split_routers(current)
explain_problems(problems, services)
phases.mark("cert_rows")
error_5xx, error_4xx = error_cutoffs(thresholds)
page = await render_template(
+13 -1
View File
@@ -45,7 +45,7 @@
{{ status_badge(r.state, "Failing" if r.state == 'crit' else "Warning") }}
<h3 class="section-title" style="margin:0;word-break:break-all;">{{ r.name }}</h3>
</div>
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}
<p class="problem-detail" style="margin-bottom:0.5rem;">{% if r.meaning %}<strong>{{ r.meaning.label }}</strong>: {% endif %}{{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}</p>
{% if r.codes %}
<p class="field-hint" style="margin:0 0 0.5rem;">Last 15 min:
@@ -56,6 +56,18 @@
{% else %}
<p class="field-hint" style="margin:0 0 0.5rem;">Status codes not recorded yet: Steward keeps them from this version on.</p>
{% endif %}
{% if r.meaning %}
{# What it means and the one next step (#5712; plugins/traefik/meaning.py). #}
<p style="margin:0 0 0.3rem;font-size:0.85rem;">{{ r.meaning.meaning }} <span class="tone-muted">{{ r.meaning.likely }}</span></p>
<p class="field-hint" style="margin:0 0 0.5rem;">Next:
{%- if r.meaning.action == 'act' %}
{%- if r.service %} <a href="/plugins/docker/service/{{ r.service|urlencode }}">{{ r.service }}: its tasks and logs →</a>
{%- else %} look at the app's container and logs under <a href="/plugins/docker/swarm">Docker → Swarm</a>; Steward can't tell which service serves this router.
{%- endif %}
{%- else %} if this is normal for this router, raise the 4xx cutoff in <a href="/settings/thresholds/#traefik">Settings → Thresholds</a>. It is one cutoff for every router
{%- if not r.normed %}; once Steward has learned this router's normal for the hour, it flags only departures from it{% endif %}.
{%- endif %}</p>
{% endif %}
<table class="table" style="font-size:0.85rem;">
<tr><td class="field-hint">Req/s</td><td>{{ "%.2f"|format(r.latest.request_rate) }}</td><td>{{ r.sparkline_req|safe }}</td></tr>
<tr><td class="field-hint">Bandwidth</td><td>{{ r.latest.response_bytes_rate|rate }}</td><td>{{ r.sparkline_bytes|safe }}</td></tr>
+15
View File
@@ -77,6 +77,21 @@ def get_loaded_plugin_modules() -> dict[str, object]:
return dict(_PLUGIN_MODULES)
def loaded_plugin_submodule(name: str, sub: str):
"""A loaded plugin's submodule (e.g. ("docker", "models")), or None when
the plugin isn't loaded. Another plugin reaches its models this way:
importing plugins.<name>.models by path would define its tables a second
time, because the plugin was loaded as _steward_plugin_<name> (#5775)."""
import importlib
loaded = _PLUGIN_MODULES.get(name)
if loaded is None:
return None
try:
return importlib.import_module(f"{loaded.__name__}.{sub}")
except ImportError:
return None
def _collect_plugin_module(app: "Quart", name: str, module) -> None:
"""Remember a loaded plugin module; hand it to a live MCP server if mounted.
+129
View File
@@ -0,0 +1,129 @@
"""Each router problem says what it means, whether it's real, and what to do
(#5712): plugins/traefik/meaning.py's wording, and the problem card that
carries it."""
import pytest
from plugins.traefik import meaning
from plugins.traefik.routes import STATE_WINDOW_WORDS, explain_problems, router_state
from steward.core.settings import to_thresholds_cfg
from tests._templates import render_plugin_template
from tests.plugins.traefik.test_traefik_page import _latest
TH = to_thresholds_cfg({})
@pytest.mark.parametrize("latest, kind", [
(_latest(e5=2.5), "5xx"),
(_latest(p95=450.0), "slow"),
(_latest(e4=5.0), "4xx"),
(_latest(e5=2.5, e4=5.0, p95=450.0), "5xx"), # graded on its worst
(_latest(p95=450.0, e4=5.0), "slow"), # router_state checks p95 before 4xx
(_latest(), None),
])
def test_the_kind_follows_router_state_wording(latest, kind):
for over in ("", STATE_WINDOW_WORDS):
state, why = router_state(latest, TH, over)
assert meaning.problem_kind(state, why) == kind
@pytest.mark.parametrize("codes, says, action", [
([("500", 30), ("502", 2)], "the app is failing requests", "act"),
([("502", 40), ("500", 1)], "couldn't reach the app", "act"),
([("504", 5)], "Mostly 504: Traefik couldn't reach the app, or it timed out", "act"),
([], "The app is answering with server errors", "act"),
])
def test_server_errors_are_real_and_point_at_the_app(codes, says, action):
got = meaning.explain("5xx", codes)
assert says in got["meaning"] and got["action"] == action and "real fault" in got["likely"]
@pytest.mark.parametrize("codes, says, action", [
([("404", 1812), ("401", 9)], "scanners and bots", "tune"),
([("401", 300), ("404", 2)], "a login wall doing its job", "tune"),
([("403", 30)], "Mostly 403: a login wall", "tune"),
([("429", 12)], "rate limiting", "tune"),
([("499", 50), ("404", 10)], "clients giving up", "act"), # mostly 499: a slow app
([("404", 50), ("499", 10)], "scanners and bots", "tune"),
([("400", 7)], "Mostly 400: requests the app turned away", "tune"),
([], "often bots, scanners or a login wall", "tune"),
])
def test_client_errors_say_which_kind_of_traffic(codes, says, action):
got = meaning.explain("4xx", codes)
assert says in got["meaning"] and got["action"] == action
def test_a_4xx_past_its_own_normal_says_something_changed():
assert "Usually traffic" in meaning.explain("4xx", [("404", 9)])["likely"]
assert "Above its usual level" in meaning.explain("4xx", [("404", 9)], normed=True)["likely"]
def test_slow_compares_p95_with_p50():
tail = meaning.explain("slow", [], p50_ms=40.0, p95_ms=900.0)
assert "p95 is 22× p50 (40 ms): a few slow requests" in tail["meaning"] and tail["action"] == "act"
whole = meaning.explain("slow", [], p50_ms=600.0, p95_ms=900.0)
assert "the whole app is slow" in whole["meaning"]
assert meaning.explain("slow", [], p95_ms=900.0)["meaning"] == "The app is slow overall, or one endpoint is."
assert "Slower than usual" in meaning.explain("slow", [], normed=True)["likely"]
SERVICES = ["arr_radarr", "codex_app", "codex_db", "steward_app", "steward_db",
"personal_nextcloud", "fabled_search_searxng", "traefik_router", "radarr_old_radarr"]
@pytest.mark.parametrize("router, service", [
("codex@swarm", "codex_app"), # its stack's app
("steward@swarm", "steward_app"),
("nextcloud@swarm", "personal_nextcloud"), # the one service ending in it
("fabled-searxng@swarm", "fabled_search_searxng"), # by its last word
("radarr@swarm", None), # two services end in _radarr: can't tell
("home@swarm", None),
("jellyfin@file", None), # not a swarm router
("traefik_router@docker", "traefik_router"),
])
def test_a_router_finds_the_swarm_service_behind_it(router, service):
assert meaning.swarm_service(router, SERVICES) == service
def _problem(latest, codes):
state, why = router_state(latest, TH)
return {"name": "app@swarm", "latest": latest, "state": state, "why": why, "codes": codes,
"normed": False, **{f"sparkline_{k}": "" for k in ("req", "bytes", "p95", "4xx", "5xx")}}
async def _card(app, router, codes_known=True):
async with app.test_request_context("/plugins/traefik/rows"):
page = await render_plugin_template(
app, "traefik", "traefik/rows.html", problems=[router], healthy=[], gone=[], global_stat=None,
certs=[], old_certs=[], range_key="24h", current_range="24h", error_4xx=1.0, error_5xx=0.1,
codes_known=codes_known)
page = " ".join(page.split())
return page[page.index(">app@swarm</h3>"):]
async def test_a_404_heavy_card_says_scanners_and_points_to_tuning(app):
r = _problem(_latest(e4=6.0), [("404", 1812), ("401", 9)])
explain_problems([r], ["app_app"])
card = await _card(app, r)
assert "<strong>Client errors</strong>: 6.0% of responses are 4xx" in card
assert "scanners and bots" in card and "Usually traffic rather than a fault" in card
assert 'href="/settings/thresholds/#traefik"' in card and "/plugins/docker/service/" not in card
assert card.index("scanners") < card.index("<table")
async def test_a_502_card_points_to_its_service(app):
r = _problem(_latest(e5=2.5), [("502", 40)])
explain_problems([r], ["app_app", "app_db"])
card = await _card(app, r)
assert "<strong>Server errors</strong>" in card and "couldn't reach the app" in card
assert 'href="/plugins/docker/service/app_app">app_app: its tasks and logs' in card
assert "/settings/thresholds/" not in card
async def test_a_card_for_an_unknown_service_says_so(app):
r = _problem(_latest(e5=2.5), [])
explain_problems([r], [])
card = await _card(app, r, codes_known=False)
assert "Status codes not recorded yet" in card
assert "The app is answering with server errors" in card
assert "Steward can't tell which service serves this router" in card