Files
FabledSteward/tests/integration/test_host_metrics.py
T
bvandeusen 8af297670e
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m6s
feat(metrics): roll plugin_metrics up to hourly to bound storage
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
2026-06-20 21:08:30 -04:00

162 lines
6.8 KiB
Python

"""Integration: host-metrics read paths (DISTINCT ON latest + SQL date_bin history).
Validates the two host_agent query helpers that back the slow host views, against
a live Postgres: the latest-per-(resource,metric) lookup and the bucket-averaged
history (which now aggregates in SQL instead of shipping raw rows to Python).
Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_latest_distinct_on_and_sql_bucketed_history(app):
from sqlalchemy import text
from steward.models.metrics import PluginMetric
from plugins.host_agent.metrics_query import (
SOURCE_MODULE, _history_for_host, _latest_metrics_for_host,
)
now = datetime.now(timezone.utc)
hostname = "metrics-host-" + uuid.uuid4().hex[:8]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(
text("DELETE FROM plugin_metrics WHERE resource_name LIKE :p"),
{"p": hostname + "%"},
)
rows = []
# Host-level CPU samples, oldest → newest (10, 20, 30).
for i, val in enumerate([10.0, 20.0, 30.0]):
rows.append(PluginMetric(
source_module=SOURCE_MODULE, resource_name=hostname,
metric_name="cpu_pct", value=val,
recorded_at=now - timedelta(minutes=30 - i * 10),
))
# A sub-resource (root mount) to exercise the host:% match.
rows.append(PluginMetric(
source_module=SOURCE_MODULE, resource_name=hostname + ":/",
metric_name="disk_used_pct", value=80.0,
recorded_at=now - timedelta(minutes=5),
))
s.add_all(rows)
latest = await _latest_metrics_for_host(s, hostname)
hist = await _history_for_host(s, hostname, now - timedelta(hours=1))
return latest, hist
latest, hist = asyncio.run(_go())
# DISTINCT ON returns the newest sample per (resource, metric).
assert latest[hostname]["cpu_pct"] == 30.0
assert latest[hostname + ":/"]["disk_used_pct"] == 80.0
# SQL date_bin aggregation returns cpu_pct buckets; averages stay in range.
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"