fix(traefik): bucket history with date_bin; integer-epoch division never grouped scrapes (#5256)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 14s

CI 8551 integration: "integer out of range" for a 2099 timestamp.

The SQL in the log showed a second problem: `CAST(epoch AS INTEGER) / 60`
compiled to `/ CAST(60 AS NUMERIC)`, i.e. decimal division. Every scrape got
its own "bucket", so the request chart and the Traefik page's sparklines
never averaged anything. The int4 cast would also overflow in 2038.

Both now use date_bin from the epoch, as host_agent/metrics_query.py does.
The integration test adds a second scrape 30 s later in the same minute and
checks that the bucket averages the totals and weights the 5xx share over
both scrapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:33:38 -04:00
co-authored by Claude Opus 5.5
parent 39b6066f3b
commit c3ba850a9c
2 changed files with 22 additions and 10 deletions
+10 -5
View File
@@ -48,24 +48,29 @@ def test_each_router_reads_as_its_newest_row(app):
def test_the_request_chart_sums_routers_and_weights_the_5xx_share(app):
"""#5256: two routers in one scrape are 4 req/s in total, not an average
of 2; a 5xx share of 50% on the 3 req/s router is 37.5% of all traffic."""
of 2; a 5xx share of 50% on the 3 req/s router is 37.5% of that scrape's
traffic. A second scrape 30 s later shares the minute's bucket, so the
bucket averages the two totals and weights the share over both."""
from plugins.traefik.models import TraefikMetric
from plugins.traefik.routes import request_history
# Far ahead of every other test's rows, so only these fall in the window.
at = datetime(2099, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=uuid.uuid4().int % 10_000)
def row(name, rate, e5):
return TraefikMetric(router_name=name, scraped_at=at, request_rate=rate, error_rate_4xx_pct=0.0,
def row(name, rate, e5, when=None):
return TraefikMetric(router_name=name, scraped_at=when or at, request_rate=rate, error_rate_4xx_pct=0.0,
error_rate_5xx_pct=e5, latency_p50_ms=1.0, latency_p95_ms=2.0,
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("quiet@docker", 1.0, 0.0), row("busy@docker", 3.0, 50.0)])
later = at + timedelta(seconds=30)
s.add_all([row("quiet@docker", 1.0, 0.0), row("busy@docker", 3.0, 50.0),
row("quiet@docker", 2.0, 0.0, later), row("busy@docker", 4.0, 0.0, later)])
async with s.begin():
return await request_history(s, at, 60)
got = asyncio.run(go())
assert got == [{"at": at, "req_rate": 4.0, "err_pct": 37.5}]
# One bucket: totals 4 and 6 average to 5; 150 weighted 5xx over 10 req/s is 15%.
assert got == [{"at": at, "req_rate": 5.0, "err_pct": 15.0}]