diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index 92520f4..120dbda 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from datetime import datetime, timedelta, timezone from quart import Blueprint, current_app, render_template, request -from sqlalchemy import Integer, cast, func, select, text +from sqlalchemy import func, select, text import json from steward.auth.middleware import require_role @@ -186,9 +186,7 @@ async def rows(): routers = sorted(newest) b_secs = bucket_seconds(since) - bucket_col = ( - cast(func.extract('epoch', TraefikMetric.scraped_at), Integer) / b_secs - ).label("bucket") + bucket_col = _time_bucket(TraefikMetric.scraped_at, b_secs) router_data = [] for router in routers: @@ -375,6 +373,15 @@ async def widget_access_log(): summary=summary, widget_id=widget_id) +def _time_bucket(column, seconds: int): + """The start of the `seconds`-wide window a timestamp falls in, as + host_agent/metrics_query.py buckets. Integer epoch / int rendered as + numeric division (no two scrapes shared a bucket) and overflowed int4 + after 2038 (#5256).""" + return func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, int(seconds)), column, + func.to_timestamp(0)).label("bucket") + + async def request_history(db, since, bucket_seconds: int) -> list[dict]: """[{"at", "req_rate", "err_pct"}] per time bucket since `since`: each scrape summed over routers (the total req/s), averaged over the bucket; @@ -390,7 +397,7 @@ async def request_history(db, since, bucket_seconds: int) -> list[dict]: .group_by(TraefikMetric.scraped_at) .subquery() ) - bucket_col = (cast(func.extract("epoch", per_scrape.c.at), Integer) / bucket_seconds).label("bucket") + bucket_col = _time_bucket(per_scrape.c.at, bucket_seconds) rows = (await db.execute( select( func.avg(per_scrape.c.req).label("req_rate"), diff --git a/tests/integration/test_traefik_latest.py b/tests/integration/test_traefik_latest.py index 916a7b2..9ed2a0a 100644 --- a/tests/integration/test_traefik_latest.py +++ b/tests/integration/test_traefik_latest.py @@ -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}]