Files
FabledSteward/tests/plugins/prometheus/test_pages.py
T
bvandeusenandClaude Opus 5.5 3cb2a95054
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m12s
CI / unit (push) Failing after 1m27s
CI / publish (push) Skipped
feat(prometheus): scrape any /metrics endpoint into Steward's metrics, with pages (#5668, #5669)
A bundled prometheus plugin, on by default like the other protocol plugins.
Targets are rows edited on its page: address, bearer token (encrypted),
interval, keep/leave-out name patterns and a series cap. Steward does the
filtering; the endpoint serves everything (Archives' ruling).

- A 5 s tick scrapes due targets at once, each within a 30 s deadline, and
  writes the outcome to its row and its series through record_metrics, so
  alert rules can watch any of them.
- Gauges are stored as they are; counters (and a histogram's or summary's
  _sum/_count) as <name>_per_sec rates from the second scrape. Buckets and
  quantiles are skipped. A labelled series is stored under
  "<target>:k=v,…". Each target also records is_up, scrape_ms, series_kept
  and series_capped.
- Pages: targets not answering first, with why; a target's series grouped
  by metric with their newest values; a chart per series; the editor with
  Test, which scrapes once and says what would be stored.
- The counter-to-rate keeper moves to steward/core/counter_rates.py; SNMP's
  RateKeeper now scales its rate on top of it.
- metric_value reads Prometheus's _seconds as a span and _per_sec as a rate.
- The catalog guard treats an empty list as a dynamic source (snmp,
  prometheus), as the rule form does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:01 -04:00

101 lines
6.0 KiB
Python

"""The Prometheus pages (#5669): targets not answering first and saying why,
what was left out, the series a target stored, and the editor."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from plugins.prometheus.routes import group_series, shown_value
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
def _view(name, reach, says="Answering.", ok_minutes=None, capped=0):
now = datetime.now(timezone.utc)
return {"id": f"id-{name}", "name": name, "url": f"http://{name}:8081/metrics", "reach": reach,
"says": says, "scraped_at": now - timedelta(seconds=20),
"ok_at": now - timedelta(minutes=ok_minutes) if ok_minutes is not None else None,
"interval": 60, "kept": 120, "dropped": 30, "capped": capped, "cap": 1000,
"keep": ["fabledarchives_*"], "drop": [], "scrape_ms": 42.4}
async def test_a_target_not_answering_says_why_and_the_cap_is_shown(app):
from quart import session
targets = [_view("archives", "down", "Answered 401: the token was refused.", ok_minutes=180),
_view("busy", "up", capped=37), _view("new", "pending")]
async with app.test_request_context("/plugins/prometheus/"):
session["user_role"] = "admin"
page = await render_plugin_template(app, "prometheus", "prometheus/index.html", targets=targets)
page = " ".join(page.split())
assert '<span class="badge badge-crit">Not answering</span>' in page
assert "Answered 401: the token was refused." in page and "Last answered" in page
assert "37 series weren't stored" in page and "the cap of 1000" in page
assert '<span class="stat-tile-value">42 ms</span>' in page
assert "the first scrape runs within seconds of saving" in page
assert 'href="/plugins/prometheus/targets/new"' in page and 'href="/plugins/prometheus/targets/id-busy/edit"' in page
main = page[page.index("<main"):]
assert not hex_colours(main), hex_colours(main)
async def test_a_viewer_sees_no_editing_and_an_empty_page_offers_to_add(app):
from quart import session
async with app.test_request_context("/plugins/prometheus/"):
session["user_role"] = "viewer"
page = await render_plugin_template(app, "prometheus", "prometheus/index.html",
targets=[_view("archives", "up")])
session["user_role"] = "admin"
empty = await render_plugin_template(app, "prometheus", "prometheus/index.html", targets=[])
assert "/edit" not in page and "targets/new" not in page
assert "No targets yet." in empty and 'href="/plugins/prometheus/targets/new"' in empty
def test_series_are_grouped_by_metric_with_their_labels_and_units():
at = datetime(2026, 10, 12, 12, 0, tzinfo=timezone.utc)
stamp = (at - timedelta(hours=2)).timestamp()
rows = [NS(resource_name="A", metric_name="is_up", value=1.0, recorded_at=at),
NS(resource_name="A", metric_name="fa_disk_free_bytes", value=2 * 1024 ** 3, recorded_at=at),
NS(resource_name="A:target=S,state=late", metric_name="fa_state", value=0.0, recorded_at=at),
NS(resource_name="A:target=S,state=fresh", metric_name="fa_state", value=1.0, recorded_at=at),
NS(resource_name="A:target=S", metric_name="fa_last_backup_timestamp_seconds", value=stamp,
recorded_at=at)]
groups = group_series("A", rows)
assert [g["metric"] for g in groups] == ["fa_disk_free_bytes", "fa_last_backup_timestamp_seconds", "fa_state"]
assert groups[0]["rows"][0]["labels"] == "" and groups[0]["rows"][0]["text"] == "2.0 GiB"
assert groups[1]["rows"][0]["moment"] == datetime.fromtimestamp(stamp, timezone.utc)
assert [r["labels"] for r in groups[2]["rows"]] == ["target=S,state=fresh", "target=S,state=late"]
assert shown_value("x_timestamp_seconds", 0) == {"text": "never", "moment": None}
assert shown_value("jobs_per_sec", 0.25)["text"] == "0.25/s"
assert shown_value("job_seconds", 90)["text"] == "1m 30s"
assert shown_value("anything", None)["text"] == "—"
async def test_the_target_page_leads_with_its_state_then_its_series(app):
from quart import session
groups = [{"metric": "fa_state", "rows": [
{"labels": "target=S,state=late", "resource": "archives:target=S,state=late", "at": None,
"text": "0", "moment": None}]}]
async with app.test_request_context("/plugins/prometheus/targets/id-archives"):
session["user_role"] = "viewer"
page = await render_plugin_template(app, "prometheus", "prometheus/target.html",
target=_view("archives", "up", capped=3), groups=groups,
total=1, q="")
page = " ".join(page.split())
assert page.index("Answering") < page.index("Series stored") < page.index("fa_state")
assert "3 series weren't stored" in page and "Keeping fabledarchives_*" in page
assert "resource=archives%3Atarget%3DS%2Cstate%3Dlate" in page and "metric=fa_state" in page
assert 'href="/plugins/prometheus/">Prometheus</a>' in page # its breadcrumb
assert "/edit" not in page
async def test_the_form_never_echoes_the_token_and_offers_test(app):
from quart import session
target = NS(id="t1", name="archives")
v = {"name": "archives", "url": "http://archives:8081/metrics", "interval_seconds": 60,
"series_cap": 1000, "keep": "fabledarchives_*", "drop": "", "verify_tls": True,
"enabled": True, "has_token": True}
async with app.test_request_context("/plugins/prometheus/targets/t1/edit"):
session["user_role"] = "admin"
page = await render_plugin_template(app, "prometheus", "prometheus/form.html",
target=target, v=v, error=None)
assert 'name="token" type="password"' in page and "kept; type to change" in page
assert 'name="clear_token"' in page and 'hx-post="/plugins/prometheus/targets/test"' in page
assert "fabledarchives_*" in page and "Delete target" in page