fix(prometheus): keep/drop patterns split on any whitespace, and a *_time_seconds value reads as a moment (#6423)
A pasted drop list whose line breaks had become spaces was stored as one pattern that matched nothing, so Home Assistant's two 526-series bookkeeping families filled the 1,000-series cap. No metric name holds whitespace, so patterns now split on spaces and tabs as well as lines and commas. Home Assistant names its Unix times *_time_seconds (e.g. homeassistant_last_updated_time_seconds), which read as "20751d 21h". is_moment() takes both suffixes, for the value shown and the chart axis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ from steward.models.users import UserRole
|
|||||||
from . import library
|
from . import library
|
||||||
from .models import PrometheusTarget, PrometheusWidgetTemplate
|
from .models import PrometheusTarget, PrometheusWidgetTemplate
|
||||||
from .scraper import ScrapeError, scrape
|
from .scraper import ScrapeError, scrape
|
||||||
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, map_families,
|
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, is_moment, map_families,
|
||||||
parse_form, patterns, shown_value, target_state)
|
parse_form, patterns, shown_value, target_state)
|
||||||
|
|
||||||
prometheus_bp = Blueprint("prometheus", __name__, template_folder="templates")
|
prometheus_bp = Blueprint("prometheus", __name__, template_folder="templates")
|
||||||
@@ -325,7 +325,7 @@ async def series_detail(target_id: str):
|
|||||||
series = await points(db, SOURCE, resource, metric,
|
series = await points(db, SOURCE, resource, metric,
|
||||||
datetime.now(timezone.utc) - timedelta(hours=hours))
|
datetime.now(timezone.utc) - timedelta(hours=hours))
|
||||||
labels = resource[len(target.name) + 1:] if resource != target.name else ""
|
labels = resource[len(target.name) + 1:] if resource != target.name else ""
|
||||||
stamp_axis = metric.endswith("_timestamp_seconds")
|
stamp_axis = is_moment(metric)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"prometheus/series.html", target=target, resource=resource, metric=metric, labels=labels,
|
"prometheus/series.html", target=target, resource=resource, metric=metric, labels=labels,
|
||||||
now=shown_value(metric, now_row.value if now_row else None), hours=hours, ranges=RANGES,
|
now=shown_value(metric, now_row.value if now_row else None), hours=hours, ranges=RANGES,
|
||||||
|
|||||||
@@ -122,13 +122,23 @@ def fold_states(metric: str, rows: list[dict]) -> list[dict] | None:
|
|||||||
return folded
|
return folded
|
||||||
|
|
||||||
|
|
||||||
|
# A series named for a moment holds Unix seconds, not a span of time: the
|
||||||
|
# convention's *_timestamp_seconds, and the *_time_seconds some exporters use
|
||||||
|
# (Home Assistant's homeassistant_last_updated_time_seconds).
|
||||||
|
MOMENT_SUFFIXES = ("_timestamp_seconds", "_time_seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def is_moment(metric: str) -> bool:
|
||||||
|
return metric.endswith(MOMENT_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
def shown_value(metric: str, value: float | None) -> dict:
|
def shown_value(metric: str, value: float | None) -> dict:
|
||||||
"""A series' value in its unit, from the Prometheus naming conventions.
|
"""A series' value in its unit, from the Prometheus naming conventions.
|
||||||
A *_timestamp_seconds is a moment: it comes back as `moment` for the page
|
A moment (is_moment) comes back as `moment` for the page to show as
|
||||||
to show as "3m ago" in the reader's zone; 0 there means "never"."""
|
"3m ago" in the reader's zone; 0 there means "never"."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return {"text": "—", "moment": None}
|
return {"text": "—", "moment": None}
|
||||||
if metric.endswith("_timestamp_seconds"):
|
if is_moment(metric):
|
||||||
if value <= 0:
|
if value <= 0:
|
||||||
return {"text": "never", "moment": None}
|
return {"text": "never", "moment": None}
|
||||||
return {"text": "", "moment": datetime.fromtimestamp(value, timezone.utc)}
|
return {"text": "", "moment": datetime.fromtimestamp(value, timezone.utc)}
|
||||||
@@ -184,11 +194,12 @@ def map_families(target: str, families: dict[str, prometheus.Family], keep: list
|
|||||||
# ── The form ─────────────────────────────────────────────────────────────────
|
# ── The form ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def patterns(text: str) -> list[str]:
|
def patterns(text: str) -> list[str]:
|
||||||
"""Name patterns from a textarea: one per line or comma-separated."""
|
"""Name patterns from a textarea: one per line, or separated by commas or
|
||||||
|
spaces. A metric name never holds whitespace, so a paste that turned line
|
||||||
|
breaks into spaces still reads as the patterns it was."""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for line in (text or "").replace(",", "\n").splitlines():
|
for p in (text or "").replace(",", " ").split():
|
||||||
p = line.strip()
|
if p not in out:
|
||||||
if p and p not in out:
|
|
||||||
out.append(p)
|
out.append(p)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{# plugins/prometheus/templates/prometheus/series.html — one stored series
|
{# plugins/prometheus/templates/prometheus/series.html — one stored series
|
||||||
of a target (milestone 506): its newest value and a chart over a chosen
|
of a target (milestone 506): its newest value and a chart over a chosen
|
||||||
range. A *_timestamp_seconds series charts as moments, not numbers. #}
|
range. A moment series (*_timestamp_seconds, *_time_seconds) charts as
|
||||||
|
moments, not numbers. #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import crumbs, page_header %}
|
{% from "_macros.html" import crumbs, page_header %}
|
||||||
{% block title %}{{ metric }} — {{ target.name }} — Steward{% endblock %}
|
{% block title %}{{ metric }} — {{ target.name }} — Steward{% endblock %}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ def test_series_are_grouped_by_metric_with_their_labels_and_units():
|
|||||||
("target=S", "fresh", ["fresh", "late"])]
|
("target=S", "fresh", ["fresh", "late"])]
|
||||||
assert groups[2]["rows"][0]["resource"] == "A:target=S,state=fresh"
|
assert groups[2]["rows"][0]["resource"] == "A:target=S,state=fresh"
|
||||||
assert shown_value("x_timestamp_seconds", 0) == {"text": "never", "moment": None}
|
assert shown_value("x_timestamp_seconds", 0) == {"text": "never", "moment": None}
|
||||||
|
# Home Assistant names its Unix times *_time_seconds (#6423): a moment, not "20751d 21h".
|
||||||
|
assert shown_value("homeassistant_last_updated_time_seconds", 1792964340.0) == {
|
||||||
|
"text": "", "moment": datetime.fromtimestamp(1792964340.0, timezone.utc)}
|
||||||
assert shown_value("jobs_per_sec", 0.25)["text"] == "0.25/s"
|
assert shown_value("jobs_per_sec", 0.25)["text"] == "0.25/s"
|
||||||
assert shown_value("job_seconds", 90)["text"] == "1m 30s"
|
assert shown_value("job_seconds", 90)["text"] == "1m 30s"
|
||||||
assert shown_value("anything", None)["text"] == "—"
|
assert shown_value("anything", None)["text"] == "—"
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ def test_names():
|
|||||||
assert resource_for("T", ()) == "T"
|
assert resource_for("T", ()) == "T"
|
||||||
assert resource_for("T", (("a", "1"), ("b", "x"))) == "T:a=1,b=x"
|
assert resource_for("T", (("a", "1"), ("b", "x"))) == "T:a=1,b=x"
|
||||||
assert patterns("a_*, b_*\n\n a_* \nc") == ["a_*", "b_*", "c"]
|
assert patterns("a_*, b_*\n\n a_* \nc") == ["a_*", "b_*", "c"]
|
||||||
|
# A paste that turned its line breaks into spaces (#6423): no metric name
|
||||||
|
# holds whitespace, so each word is a pattern.
|
||||||
|
assert patterns("ha_last_updated_time_seconds ha_entity_available\tha_entity_info") == [
|
||||||
|
"ha_last_updated_time_seconds", "ha_entity_available", "ha_entity_info"]
|
||||||
|
|
||||||
|
|
||||||
def test_labels_read_back_as_stored_even_with_a_comma_in_a_value():
|
def test_labels_read_back_as_stored_even_with_a_comma_in_a_value():
|
||||||
|
|||||||
Reference in New Issue
Block a user