Through one address Steward read whichever replica answered, and with host-mode ports behind a keepalived VIP that can be the replica serving nothing: since the 18:43 restart every scrape landed on Docker03's idle Traefik, which has no traefik_service_* series, so no router was stored. A metrics_url host name that resolves to several addresses (a swarm service's tasks.<service>, a Kubernetes headless Service, round-robin DNS) is now scraped address by address, with the original Host header and TLS name. Answers from one process are kept once; a replica that doesn't answer is skipped with a warning. Requests, bytes and latency buckets are summed over the replicas that have a baseline, so the sum's rise is the sum of each one's own rise; certificates are listed once; Traefik's own figures add connections and memory and keep the newest reload. A name with one address is one scrape, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
"""#5844: one metrics URL can reach several Traefik processes (swarm replicas,
|
|
pods, a keepalived pair, a restart). Each is counted only against its own last
|
|
scrape, so one replica's lifetime totals never read as another's new traffic."""
|
|
from datetime import datetime, timezone
|
|
from functools import partial
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from plugins.traefik import scraper
|
|
from plugins.traefik.scraper import (
|
|
BASELINE_TTL_SECONDS, Baselines, combine, compute_router_metrics, instance_of, merge_global_stats, unique_certs,
|
|
)
|
|
|
|
REQ = "traefik_service_requests_total"
|
|
|
|
|
|
def _scrape(started: float | None, **counts):
|
|
"""A scrape from the process started at `started`, with {code: total} for qbittorrent@file."""
|
|
out = {REQ: {frozenset({"service": "qbittorrent@file", "code": c, "method": "GET"}.items()): v
|
|
for c, v in counts.items()}}
|
|
if started is not None:
|
|
out["process_start_time_seconds"] = {frozenset(): started}
|
|
return out
|
|
|
|
|
|
def test_each_process_is_compared_only_with_itself():
|
|
b = Baselines()
|
|
a1, b1 = _scrape(1000.0, **{"200": 10}), _scrape(2000.0, **{"502": 336})
|
|
assert b.swap(a1, 0.0) == (None, 0.0)
|
|
# The other replica answers: it has no baseline yet, so nothing is counted.
|
|
assert b.swap(b1, 60.0) == (None, 0.0)
|
|
# Back to the first: compared with its own scrape two minutes ago.
|
|
a2 = _scrape(1000.0, **{"200": 22})
|
|
assert b.swap(a2, 120.0) == (a1, 120.0)
|
|
assert b.swap(_scrape(2000.0, **{"502": 336}), 180.0) == (b1, 120.0)
|
|
|
|
|
|
def test_landing_on_the_other_replica_no_longer_reads_its_totals_as_traffic():
|
|
"""The live symptom: an idle retired router flashing 336 502s, 100% 5xx."""
|
|
b = Baselines()
|
|
b.swap(_scrape(1000.0, **{"200": 10}), 0.0)
|
|
previous, elapsed = b.swap(_scrape(2000.0, **{"502": 336}), 60.0)
|
|
got = compute_router_metrics(_scrape(2000.0, **{"502": 336}), previous, elapsed)
|
|
assert got["qbittorrent@file"]["request_rate"] == 0.0
|
|
assert got["qbittorrent@file"]["error_rate_5xx_pct"] == 0.0
|
|
|
|
|
|
def test_a_restarted_traefik_starts_a_new_baseline():
|
|
b = Baselines()
|
|
b.swap(_scrape(1000.0, **{"200": 5000}), 0.0)
|
|
assert b.swap(_scrape(3000.0, **{"200": 3}), 60.0) == (None, 0.0)
|
|
|
|
|
|
def test_without_a_start_time_every_scrape_is_one_process_as_before():
|
|
b = Baselines()
|
|
first = _scrape(None, **{"200": 1})
|
|
assert instance_of(first) == ""
|
|
b.swap(first, 0.0)
|
|
assert b.swap(_scrape(None, **{"200": 2}), 60.0) == (first, 60.0)
|
|
|
|
|
|
def test_a_baseline_older_than_the_ttl_is_dropped_not_compared():
|
|
b = Baselines()
|
|
b.swap(_scrape(1000.0, **{"200": 1}), 0.0)
|
|
assert b.swap(_scrape(1000.0, **{"200": 900}), BASELINE_TTL_SECONDS + 1) == (None, 0.0)
|
|
|
|
|
|
# ── Every replica, added up (#5844) ──────────────────────────────────────────
|
|
|
|
def _router(started, service, **counts):
|
|
out = {REQ: {frozenset({"service": service, "code": c, "method": "GET"}.items()): v
|
|
for c, v in counts.items()}}
|
|
out["process_start_time_seconds"] = {frozenset(): started}
|
|
return out
|
|
|
|
|
|
def test_two_replicas_add_up_and_a_new_one_waits_for_its_baseline():
|
|
a0, a1 = _router(1.0, "app@swarm", **{"200": 100}), _router(1.0, "app@swarm", **{"200": 160})
|
|
b0, b1 = _router(2.0, "app@swarm", **{"200": 50}), _router(2.0, "app@swarm", **{"200": 70, "502": 4})
|
|
c1 = _router(3.0, "app@swarm", **{"200": 9000}) # first seen: no baseline
|
|
current, previous, elapsed = combine([(a1, a0, 60.0), (b1, b0, 60.0), (c1, None, 0.0)])
|
|
got = compute_router_metrics(current, previous, elapsed)["app@swarm"]
|
|
# (160-100) + (70-50) + 4 new 502s = 84 requests in 60 s; C's 9000 are not traffic.
|
|
assert got["request_rate"] == pytest.approx(84 / 60)
|
|
assert got["error_rate_5xx_pct"] == pytest.approx(4 / 84 * 100)
|
|
|
|
|
|
def test_the_busy_replica_counts_though_the_idle_one_has_no_routers():
|
|
"""The live case: the VIP sends everything to one replica; the other has
|
|
no traefik_service_* series at all."""
|
|
busy0, busy1 = _router(1.0, "roundtable@swarm", **{"200": 10}), _router(1.0, "roundtable@swarm", **{"200": 40})
|
|
idle = {"process_start_time_seconds": {frozenset(): 2.0}}
|
|
current, previous, elapsed = combine([(busy1, busy0, 60.0), (idle, idle, 60.0)])
|
|
assert compute_router_metrics(current, previous, elapsed)["roundtable@swarm"]["request_rate"] == 0.5
|
|
|
|
|
|
def test_with_no_baseline_yet_every_router_is_listed_at_zero():
|
|
current, previous, _ = combine([(_router(1.0, "a@swarm", **{"200": 5}), None, 0.0),
|
|
(_router(2.0, "b@swarm", **{"200": 7}), None, 0.0)])
|
|
assert previous is None
|
|
got = compute_router_metrics(current, previous, 0.0)
|
|
assert set(got) == {"a@swarm", "b@swarm"} and got["a@swarm"]["request_rate"] == 0.0
|
|
|
|
|
|
def test_traefiks_own_figures_add_up_or_take_the_newest():
|
|
t1, t2 = datetime(2026, 10, 15, 18, 0, tzinfo=timezone.utc), datetime(2026, 10, 15, 18, 43, tzinfo=timezone.utc)
|
|
got = merge_global_stats([
|
|
{"open_conns_total": 40.0, "config_reloads_total": 70.0, "config_last_reload_at": t1, "process_memory_bytes": 2e8},
|
|
{"open_conns_total": 1.0, "config_reloads_total": 72.0, "config_last_reload_at": t2, "process_memory_bytes": 1e8},
|
|
])
|
|
assert got == {"open_conns_total": 41.0, "config_reloads_total": 72.0, "config_last_reload_at": t2,
|
|
"process_memory_bytes": 3e8}
|
|
|
|
|
|
def test_a_certificate_both_replicas_serve_is_listed_once():
|
|
cert = {"traefik_tls_certs_not_after": {frozenset({"serial": "ab", "cn": "x.example", "sans": ""}.items()): 2e9}}
|
|
assert [c["serial"] for c in unique_certs([cert, cert])] == ["ab"]
|
|
|
|
|
|
EXPO = {
|
|
"10.0.2.5": "process_start_time_seconds 1.0\ntraefik_service_requests_total{service=\"a@swarm\",code=\"200\",method=\"GET\"} 10\n",
|
|
"10.0.2.6": "process_start_time_seconds 2.0\n",
|
|
"10.0.2.7": "process_start_time_seconds 1.0\n", # the same process again
|
|
}
|
|
|
|
|
|
async def test_a_name_for_several_replicas_is_scraped_address_by_address(monkeypatch):
|
|
seen_hosts = []
|
|
|
|
def answer(request: httpx.Request) -> httpx.Response:
|
|
seen_hosts.append(request.headers["host"])
|
|
if request.url.host == "10.0.2.8":
|
|
return httpx.Response(503)
|
|
return httpx.Response(200, text=EXPO[request.url.host])
|
|
|
|
async def addresses(host, port):
|
|
assert (host, port) == ("tasks.traefik_router", 8080)
|
|
return ["10.0.2.5", "10.0.2.6", "10.0.2.7", "10.0.2.8"]
|
|
|
|
monkeypatch.setattr(scraper, "_addresses", addresses)
|
|
monkeypatch.setattr(scraper.httpx, "AsyncClient",
|
|
partial(httpx.AsyncClient, transport=httpx.MockTransport(answer)))
|
|
got = await scraper.fetch_instances("http://tasks.traefik_router:8080/metrics")
|
|
# 10.0.2.8 didn't answer and 10.0.2.7 is 10.0.2.5's process: two Traefiks.
|
|
assert sorted(instance_of(g) for g in got) == ["1.00", "2.00"]
|
|
assert set(seen_hosts) == {"tasks.traefik_router:8080"}
|
|
|
|
|
|
async def test_a_name_with_one_address_is_one_scrape_as_before(monkeypatch):
|
|
async def addresses(host, port):
|
|
return ["10.0.2.5"]
|
|
|
|
monkeypatch.setattr(scraper, "_addresses", addresses)
|
|
monkeypatch.setattr(scraper.httpx, "AsyncClient", partial(
|
|
httpx.AsyncClient, transport=httpx.MockTransport(lambda r: httpx.Response(200, text=EXPO["10.0.2.5"]))))
|
|
[got] = await scraper.fetch_instances("http://trfk.traefik.internal/metrics")
|
|
assert instance_of(got) == "1.00"
|