feat(traefik): keep each router's 4xx and 5xx responses by status code (#5711)
CI / lint (push) Failing after 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m30s
CI / publish (push) Skipped

The scraper folded traefik_service_requests_total{code} into two shares and
dropped the code, so a problem couldn't say whether it was scanners (404), a
login wall (401/403) or the app down (502/503/504). Each scrape now stores the
responses per router and code that occurred (traefik_status_codes, migration
traefik_006), pruned with the router metrics and left out of backups (System
180 ruling: Traefik history is not backed up). A problem card lists its codes
over the last 15 minutes, most first, or says none occurred, or that codes
aren't recorded yet.

A counter series new since the previous scrape now counts from 0 instead of
being skipped: Traefik creates a router's 502 series at its first 502, so a
first burst of 5xx read as 0% until it happened again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 11:07:19 -04:00
co-authored by Claude Opus 5.5
parent 627193b00a
commit 81995051dc
11 changed files with 243 additions and 10 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ 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". 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. 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, and its request rate, bandwidth, p95/p99 latency and 4xx/5xx shares, each with its history. 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.
4. **Traefik's own figures:** when its config last reloaded, open connections, memory. 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. 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.
+4 -1
View File
@@ -11,10 +11,13 @@ _app: "Quart | None" = None
def setup(app: "Quart") -> None: def setup(app: "Quart") -> None:
global _app global _app
_app = app _app = app
from .models import TraefikMetric, TraefikCert, TraefikGlobalStat, TraefikAccessSummary # noqa: F401 from .models import ( # noqa: F401
TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode,
)
from steward.core.cleanup import prune_by_age from steward.core.cleanup import prune_by_age
# Samples older than data.retention_days are pruned by the cleanup (#5155). # Samples older than data.retention_days are pruned by the cleanup (#5155).
prune_by_age(TraefikMetric.scraped_at) prune_by_age(TraefikMetric.scraped_at)
prune_by_age(TraefikStatusCode.scraped_at)
prune_by_age(TraefikGlobalStat.scraped_at) prune_by_age(TraefikGlobalStat.scraped_at)
prune_by_age(TraefikAccessSummary.period_end) prune_by_age(TraefikAccessSummary.period_end)
@@ -0,0 +1,40 @@
"""Each router's 4xx and 5xx responses by status code
The scraper folded traefik_service_requests_total{code} into a 4xx share and
a 5xx share and dropped the code, so a problem couldn't say whether it was
scanners probing (404), a login wall (401/403) or the app down (502/503/504)
(#5711). One row per router, scrape and code that occurred; the page reads a
router's codes over a time window, answered from the index alone.
Revision ID: traefik_006_status_codes
Revises: traefik_005_reload_at
Create Date: 2026-10-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "traefik_006_status_codes"
down_revision: Union[str, None] = "traefik_005_reload_at"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"traefik_status_codes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("router_name", sa.String(255), nullable=False),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("code", sa.String(8), nullable=False),
sa.Column("count", sa.Integer, nullable=False),
)
op.create_index("ix_traefik_status_codes_router_scraped", "traefik_status_codes",
["router_name", "scraped_at"], postgresql_include=["code", "count"])
op.create_index("ix_traefik_status_codes_scraped", "traefik_status_codes", ["scraped_at"])
def downgrade() -> None:
op.drop_index("ix_traefik_status_codes_scraped", "traefik_status_codes")
op.drop_index("ix_traefik_status_codes_router_scraped", "traefik_status_codes")
op.drop_table("traefik_status_codes")
+14
View File
@@ -67,3 +67,17 @@ class TraefikGlobalStat(Base):
# traefik_config_last_reload_success gauge is that moment, not a 0/1 flag. # traefik_config_last_reload_success gauge is that moment, not a 0/1 flag.
config_last_reload_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) config_last_reload_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
process_memory_bytes: Mapped[float | None] = mapped_column(Float, nullable=True) process_memory_bytes: Mapped[float | None] = mapped_column(Float, nullable=True)
class TraefikStatusCode(Base):
"""How many responses a router answered with one 4xx or 5xx code in one
scrape interval (#5711): what lets a problem say whether it is scanners
probing (404), a login wall (401/403) or the app down (502/503/504).
Only codes that occurred are stored."""
__tablename__ = "traefik_status_codes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
router_name: Mapped[str] = mapped_column(String(255), nullable=False)
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
code: Mapped[str] = mapped_column(String(8), nullable=False)
count: Mapped[int] = mapped_column(Integer, nullable=False)
+30 -1
View File
@@ -13,7 +13,7 @@ from steward.models.users import UserRole
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample, time_bucket from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample, time_bucket
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 steward.models.metrics import PluginMetricLatest
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode
from .scheduler import router_domains from .scheduler import router_domains
from .scraper import cert_names, covers from .scraper import cert_names, covers
@@ -110,6 +110,25 @@ async def recent_window(db, names: list[str], now: datetime) -> dict[str, Simple
return out return out
async def status_codes(db, names: list[str], since: datetime) -> dict[str, list[tuple[str, int]]]:
"""{router: [(code, responses), …]} for the 4xx and 5xx codes each router
answered since `since`, most first (#5711). A router with none is absent."""
if not names:
return {}
total = func.sum(TraefikStatusCode.count)
rows = (await db.execute(
select(TraefikStatusCode.router_name, TraefikStatusCode.code, total.label("n"))
.where(TraefikStatusCode.router_name.in_(names))
.where(TraefikStatusCode.scraped_at >= since)
.group_by(TraefikStatusCode.router_name, TraefikStatusCode.code)
.order_by(TraefikStatusCode.router_name, total.desc(), TraefikStatusCode.code)
)).all()
out: dict[str, list[tuple[str, int]]] = {}
for r in rows:
out.setdefault(r.router_name, []).append((r.code, int(r.n)))
return out
def grade_routers(routers: list[dict], window: dict, thresholds: dict) -> None: def grade_routers(routers: list[dict], window: dict, thresholds: dict) -> None:
"""Set each router's state and why from its recent window, or from its """Set each router's state and why from its recent window, or from its
latest scrape when the window holds nothing.""" latest scrape when the window holds nothing."""
@@ -284,6 +303,15 @@ async def rows():
r["sparkline_4xx"] = _sparkline([p.error_rate_4xx_pct for p in points]) r["sparkline_4xx"] = _sparkline([p.error_rate_4xx_pct for p in points])
r["sparkline_5xx"] = _sparkline([p.error_rate_5xx_pct for p in points]) r["sparkline_5xx"] = _sparkline([p.error_rate_5xx_pct for p in points])
phases.mark("sparklines") phases.mark("sparklines")
# The codes behind each problem, over the window it is graded on.
codes = await status_codes(db, [r["name"] for r in current if r["state"] != "ok"], now - STATE_WINDOW)
for r in current:
r["codes"] = codes.get(r["name"], [])
# Codes are kept from #5711 on: until the first of them is a window
# 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
phases.mark("codes")
# Latest global stat (always most recent, not range-filtered) # Latest global stat (always most recent, not range-filtered)
result = await db.execute( result = await db.execute(
@@ -329,6 +357,7 @@ async def rows():
healthy=healthy, healthy=healthy,
gone=gone, gone=gone,
current_range=range_key, current_range=range_key,
codes_known=codes_known,
error_4xx=error_4xx, error_4xx=error_4xx,
error_5xx=error_5xx, error_5xx=error_5xx,
global_stat=global_stat, global_stat=global_stat,
+9 -1
View File
@@ -51,11 +51,12 @@ async def _do_scrape(app: "Quart") -> None:
global _prev_metrics, _prev_time, _router_domains global _prev_metrics, _prev_time, _router_domains
from datetime import datetime, timezone from datetime import datetime, timezone
from .models import TraefikCert, TraefikGlobalStat, TraefikMetric from .models import TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode
from .scraper import ( from .scraper import (
api_base, api_base,
compute_global_stats, compute_global_stats,
compute_router_metrics, compute_router_metrics,
compute_status_codes,
extract_certs, extract_certs,
fetch_metrics, fetch_metrics,
fetch_router_domains, fetch_router_domains,
@@ -81,6 +82,7 @@ async def _do_scrape(app: "Quart") -> None:
elapsed = now - _prev_time if _prev_time else 60.0 elapsed = now - _prev_time if _prev_time else 60.0
router_metrics = compute_router_metrics(current, _prev_metrics or None, elapsed) router_metrics = compute_router_metrics(current, _prev_metrics or None, elapsed)
global_stats = compute_global_stats(current, _prev_metrics or None) global_stats = compute_global_stats(current, _prev_metrics or None)
status_codes = compute_status_codes(current, _prev_metrics or None)
cert_list = extract_certs(current) cert_list = extract_certs(current)
_prev_metrics = current _prev_metrics = current
@@ -125,6 +127,12 @@ async def _do_scrape(app: "Quart") -> None:
recorded_at=scraped_at, 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 ────────────────────────────────────────────────── # ── Global stats ──────────────────────────────────────────────────
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats)) session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
+32 -6
View File
@@ -43,6 +43,33 @@ def parse_prometheus(text: str) -> ParsedMetrics:
# Per-router metric computation # Per-router metric computation
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
def _rise(prev: dict[frozenset, float] | None, labels: frozenset, value: float) -> float:
"""How far a counter rose since the previous scrape. On the first scrape
(`prev` None) nothing can be compared, so 0. A series the previous scrape
didn't have started from 0 since then, so all of it counts: Traefik makes
a router's first 502 series when the first 502 happens (#5711)."""
if prev is None:
return 0.0
return max(0.0, value - prev.get(labels, 0.0))
def compute_status_codes(current: ParsedMetrics, previous: ParsedMetrics | None) -> dict[str, dict[str, int]]:
"""{service: {code: responses since the previous scrape}} for each 4xx and
5xx code that occurred, from traefik_service_requests_total{code} (#5711).
Codes that didn't occur are left out; the first scrape has none."""
prev = None if previous is None else previous.get("traefik_service_requests_total", {})
out: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for labels, value in current.get("traefik_service_requests_total", {}).items():
label_dict = dict(labels)
service, code = label_dict.get("service", ""), label_dict.get("code", "")
if not service or code[:1] not in ("4", "5"):
continue
count = round(_rise(prev, labels, value))
if count:
out[service][code] += count
return {service: dict(codes) for service, codes in out.items()}
def compute_router_metrics( def compute_router_metrics(
current: ParsedMetrics, current: ParsedMetrics,
previous: ParsedMetrics | None, previous: ParsedMetrics | None,
@@ -63,12 +90,13 @@ def compute_router_metrics(
"response_bytes_rate": float, # bytes/sec "response_bytes_rate": float, # bytes/sec
}} }}
""" """
first = not previous
prev = previous or {} prev = previous or {}
elapsed = max(elapsed_seconds, 1.0) elapsed = max(elapsed_seconds, 1.0)
# ── Request counters ────────────────────────────────────────────────────── # ── Request counters ──────────────────────────────────────────────────────
req_samples = current.get("traefik_service_requests_total", {}) req_samples = current.get("traefik_service_requests_total", {})
prev_req = prev.get("traefik_service_requests_total", {}) prev_req = None if first else prev.get("traefik_service_requests_total", {})
service_total: dict[str, float] = defaultdict(float) service_total: dict[str, float] = defaultdict(float)
service_4xx: dict[str, float] = defaultdict(float) service_4xx: dict[str, float] = defaultdict(float)
@@ -80,8 +108,7 @@ def compute_router_metrics(
if not service: if not service:
continue continue
code = label_dict.get("code", "") code = label_dict.get("code", "")
prev_value = prev_req.get(labels, value) # no delta on first scrape delta = _rise(prev_req, labels, value)
delta = max(0.0, value - prev_value)
service_total[service] += delta service_total[service] += delta
if code.startswith("4"): if code.startswith("4"):
service_4xx[service] += delta service_4xx[service] += delta
@@ -90,15 +117,14 @@ def compute_router_metrics(
# ── Response bytes counters ─────────────────────────────────────────────── # ── Response bytes counters ───────────────────────────────────────────────
bytes_samples = current.get("traefik_service_responses_bytes_total", {}) bytes_samples = current.get("traefik_service_responses_bytes_total", {})
prev_bytes = prev.get("traefik_service_responses_bytes_total", {}) prev_bytes = None if first else prev.get("traefik_service_responses_bytes_total", {})
service_bytes: dict[str, float] = defaultdict(float) service_bytes: dict[str, float] = defaultdict(float)
for labels, value in bytes_samples.items(): for labels, value in bytes_samples.items():
service = dict(labels).get("service", "") service = dict(labels).get("service", "")
if not service: if not service:
continue continue
prev_value = prev_bytes.get(labels, value) service_bytes[service] += _rise(prev_bytes, labels, value)
service_bytes[service] += max(0.0, value - prev_value)
# ── Histogram buckets ───────────────────────────────────────────────────── # ── Histogram buckets ─────────────────────────────────────────────────────
hist_buckets = current.get("traefik_service_request_duration_seconds_bucket", {}) hist_buckets = current.get("traefik_service_request_duration_seconds_bucket", {})
@@ -42,6 +42,15 @@
</div> </div>
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }} <p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}</p> {%- 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:
{% for code, n in r.codes[:5] %}<span class="tone-{{ 'crit' if code[:1] == '5' else 'warn' }}">{{ code }}</span> ×{{ "{:,}".format(n) }}{{ ", " if not loop.last }}{% endfor %}
{%- if r.codes|length > 5 %}, {{ r.codes|length - 5 }} more code{{ "s" if r.codes|length - 5 != 1 }}{% endif %}</p>
{% elif codes_known %}
<p class="field-hint" style="margin:0 0 0.5rem;">No 4xx or 5xx responses in the last 15 min.</p>
{% 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 %}
<table class="table" style="font-size:0.85rem;"> <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">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> <tr><td class="field-hint">Bandwidth</td><td>{{ r.latest.response_bytes_rate|rate }}</td><td>{{ r.sparkline_bytes|safe }}</td></tr>
+1
View File
@@ -38,6 +38,7 @@ EXCLUDED: frozenset[str] = frozenset({
"docker_images", "docker_logs", "docker_metrics", "docker_metrics_hourly", "docker_images", "docker_logs", "docker_metrics", "docker_metrics_hourly",
"docker_swarm_nodes", "docker_swarm_services", "docker_swarm_nodes", "docker_swarm_services",
"traefik_access_summaries", "traefik_certs", "traefik_global_stats", "traefik_metrics", "traefik_access_summaries", "traefik_certs", "traefik_global_stats", "traefik_metrics",
"traefik_status_codes",
"unifi_alarms", "unifi_client_snapshots", "unifi_devices", "unifi_dpi_snapshots", "unifi_alarms", "unifi_client_snapshots", "unifi_devices", "unifi_dpi_snapshots",
"unifi_events", "unifi_fw_rules", "unifi_networks", "unifi_port_forwards", "unifi_events", "unifi_fw_rules", "unifi_networks", "unifi_port_forwards",
"unifi_speedtest_results", "unifi_wan_stats", "unifi_wlan_stats", "unifi_speedtest_results", "unifi_wan_stats", "unifi_wlan_stats",
+25
View File
@@ -152,3 +152,28 @@ def test_recent_window_weights_shares_by_traffic_and_keeps_to_the_window(app):
assert set(got) == {busy} # nothing in empty's window 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].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) assert got[busy].latency_p95_ms == pytest.approx(20.0)
def test_status_codes_sum_each_routers_codes_in_the_window_most_first(app):
"""#5711: the codes behind a router's problem, summed over the window it
is graded on; an older row and a router not asked for are not read."""
from plugins.traefik.models import TraefikStatusCode
from plugins.traefik.routes import status_codes
at = datetime(2097, 6, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=5 * (uuid.uuid4().int % 2_000))
tag = uuid.uuid4().hex[:8]
a, b, other = (f"{x}-{tag}@docker" for x in ("a", "b", "other"))
def row(name, minute, code, count):
return TraefikStatusCode(router_name=name, scraped_at=at + timedelta(minutes=minute), code=code, count=count)
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add_all([row(a, 1, "404", 30), row(a, 2, "404", 12), row(a, 2, "502", 50),
row(a, -20, "500", 999), row(b, 3, "401", 4), row(other, 3, "404", 7)])
async with s.begin():
return await status_codes(s, [a, b], at)
got = asyncio.run(go())
assert got == {a: [("502", 50), ("404", 42)], b: [("401", 4)]}
@@ -0,0 +1,78 @@
"""Each router's 4xx and 5xx responses by code (#5711): counted from
traefik_service_requests_total{code} between scrapes, and shown on the
problem card so it can say whether it's scanners, a login wall or the app."""
from datetime import datetime, timedelta, timezone
from plugins.traefik.routes import router_state
from plugins.traefik.scraper import compute_router_metrics, compute_status_codes
from steward.core.settings import to_thresholds_cfg
from tests._templates import render_plugin_template
from tests.plugins.traefik.test_traefik_page import _latest
REQ = "traefik_service_requests_total"
def _req(*series):
"""{REQ: {labels: value}} from (service, code, method, value) tuples."""
return {REQ: {frozenset({"service": s, "code": c, "method": m}.items()): v for s, c, m, v in series}}
def test_the_codes_since_the_last_scrape_are_counted_per_router():
before = _req(("app@docker", "200", "GET", 1000), ("app@docker", "404", "GET", 100),
("app@docker", "404", "POST", 10), ("api@docker", "401", "GET", 5))
after = _req(("app@docker", "200", "GET", 1500), ("app@docker", "404", "GET", 160),
("app@docker", "404", "POST", 12), ("api@docker", "401", "GET", 5),
("app@docker", "502", "GET", 3)) # a series the last scrape didn't have
got = compute_status_codes(after, before)
# 2xx is left out; 404 sums over methods; a new 502 series counts whole; api's 401 didn't move.
assert got == {"app@docker": {"404": 62, "502": 3}}
def test_the_first_scrape_counts_nothing_and_a_reset_counts_nothing():
series = _req(("app@docker", "500", "GET", 40))
assert compute_status_codes(series, None) == {}
assert compute_status_codes(_req(("app@docker", "500", "GET", 2)), series) == {} # Traefik restarted
def test_a_router_first_502s_count_in_its_5xx_share():
"""A series new since the last scrape started from 0: before #5711 its
whole first burst was dropped, so a router's first 502s read as 0%."""
before = _req(("app@docker", "200", "GET", 100))
after = _req(("app@docker", "200", "GET", 190), ("app@docker", "502", "GET", 10))
got = compute_router_metrics(after, before, 60.0)["app@docker"]
assert got["error_rate_5xx_pct"] == 10.0 and abs(got["request_rate"] - 100 / 60) < 1e-9
TH = to_thresholds_cfg({})
def _problem(**codes_ctx):
latest = _latest(e4=6.0)
state, why = router_state(latest, TH)
return {"name": "app", "latest": latest, "state": state, "why": why, **codes_ctx,
**{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</h3>"):]
async def test_the_card_lists_the_codes_most_first_before_its_figures(app):
codes = [("404", 1812), ("502", 40), ("401", 9), ("403", 3), ("429", 2), ("499", 1), ("500", 1)]
card = await _card(app, _problem(codes=codes))
assert card.index('Last 15 min: <span class="tone-warn">404</span> ×1,812, <span class="tone-crit">502</span> ×40') \
< card.index("<table")
assert "429</span> ×2, 2 more codes" in card and "499" not in card
async def test_a_card_without_codes_says_whether_they_were_recorded(app):
none = await _card(app, _problem(codes=[]), codes_known=True)
assert "No 4xx or 5xx responses in the last 15 min." in none
early = await _card(app, _problem(codes=[]), codes_known=False)
assert "Status codes not recorded yet" in early