feat(metrics): roll plugin_metrics up to hourly to bound storage
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m6s

plugin_metrics grows by (sources × resources × ~30s cadence); keeping 90d of raw
is a large table. Add a raw→hourly rollup (mirroring the Docker plugin) so only a
short raw window is kept at full resolution, with hourly averages archived longer.

- PluginMetricHourly model + core migration 0024 (plugin_metrics_hourly: avg/max/
  count per source/resource/metric/hour, unique bucket constraint + lookup index).
- steward/core/metrics_retention.rollup_plugin_metrics: date_trunc('hour') agg of
  raw older than the hour-aligned raw window, idempotent pg upsert into hourly,
  delete the rolled raw, prune hourly beyond the rollup window.
- cleanup.py: plugin_metrics is no longer blanket-deleted at data.retention_days;
  _run_metrics_retention drives the rollup with windows read live from settings.
- Settings: metrics.retention.raw_days (7) + rollup_days (90), tunable on the
  Thresholds & Retention page (new "Host metrics retention" card).
- Chart read: _history_for_host merges the hourly rollup (older part of the range)
  with raw date_bin (recent part, capped ≤1h), so 30d charts keep working —
  recent at full resolution, older at hourly. Route passes raw_days from settings.
- Tests: unit (cutoff helpers) + integration (rollup aggregates/prunes; history
  merges hourly + raw) against Postgres.

Speed was already handled by the indexes + SQL aggregation; this is the storage
lever (raw window ~10x smaller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-20 21:08:30 -04:00
parent b0d3e83bdd
commit 8af297670e
11 changed files with 369 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Unit tests for the plugin_metrics rollup cutoff helpers (no DB)."""
from datetime import datetime, timezone
from steward.core.metrics_retention import _hour_floor, _rollup_cutoff
def test_hour_floor_drops_sub_hour():
dt = datetime(2026, 6, 20, 15, 42, 9, 123456, tzinfo=timezone.utc)
assert _hour_floor(dt) == datetime(2026, 6, 20, 15, 0, 0, 0, tzinfo=timezone.utc)
def test_rollup_cutoff_is_hour_aligned_and_offset():
now = datetime(2026, 6, 20, 15, 42, 9, tzinfo=timezone.utc)
cutoff = _rollup_cutoff(now, 7)
assert (cutoff.minute, cutoff.second, cutoff.microsecond) == (0, 0, 0)
# 7 whole days back, then floored to the hour.
assert cutoff == datetime(2026, 6, 13, 15, 0, 0, 0, tzinfo=timezone.utc)
+81
View File
@@ -78,3 +78,84 @@ def test_latest_distinct_on_and_sql_bucketed_history(app):
cpu = hist["cpu_pct"]
assert cpu, "expected bucketed cpu_pct history"
assert all(10.0 <= v <= 30.0 for _, v in cpu)
@_NEEDS_DB
def test_rollup_aggregates_old_raw_and_prunes(app):
from sqlalchemy import text
from steward.core.metrics_retention import rollup_plugin_metrics
from steward.models.metrics import PluginMetric
now = datetime.now(timezone.utc)
hostname = "rollup-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
await s.execute(
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
old = (now - timedelta(days=10)).replace(minute=5, second=0, microsecond=0)
s.add_all([
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=10.0, recorded_at=old),
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=20.0,
recorded_at=old.replace(minute=35)), # same hour bucket
PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=99.0,
recorded_at=now - timedelta(hours=1)), # recent → kept raw
])
async with s.begin():
counts = await rollup_plugin_metrics(s, raw_days=7, rollup_days=90, now=now)
hrly = (await s.execute(text(
"SELECT value_avg, sample_count FROM plugin_metrics_hourly "
"WHERE resource_name = :h AND metric_name = 'cpu_pct'"), {"h": hostname})).all()
raw_left = (await s.execute(text(
"SELECT count(*) FROM plugin_metrics WHERE resource_name = :h"), {"h": hostname})).scalar()
return counts, hrly, raw_left
counts, hrly, raw_left = asyncio.run(_go())
assert counts["buckets_rolled"] == 1
assert counts["raw_rows_rolled"] == 2
assert len(hrly) == 1
assert abs(float(hrly[0][0]) - 15.0) < 0.001 # avg(10, 20)
assert hrly[0][1] == 2
assert raw_left == 1 # only the recent sample remains
@_NEEDS_DB
def test_history_merges_hourly_and_raw(app):
from sqlalchemy import text
from steward.models.metrics import PluginMetric, PluginMetricHourly
from plugins.host_agent.metrics_query import _history_for_host
now = datetime.now(timezone.utc)
hostname = "merge-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
await s.execute(
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
old_bucket = (now - timedelta(days=10)).replace(minute=0, second=0, microsecond=0)
s.add(PluginMetricHourly(
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
bucket=old_bucket, value_avg=42.0, value_max=50.0, sample_count=120))
s.add(PluginMetric(
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
value=77.0, recorded_at=now - timedelta(hours=1)))
# 30d range with a 7d raw window → spans the rollup boundary.
return await _history_for_host(s, hostname, now - timedelta(days=30), raw_days=7)
hist = asyncio.run(_go())
cpu = hist["cpu_pct"]
vals = [v for _, v in cpu]
assert 42.0 in vals, "expected the rolled-up hourly point"
assert 77.0 in vals, "expected the recent raw point"
assert cpu == sorted(cpu, key=lambda p: p[0]), "series must be time-ordered"