feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
Host CPU, memory and load per core, container CPU and memory, and every monitor's response time learn a normal per hour (milestone 513, System 182). A reading past its fixed cutoff but within its normal reads healthy and says what is usual at this hour; one well out of it keeps its grade and is a metric anomaly. Disk, a monitor down and an agent offline keep their cutoffs. - Each normal's floor, the smallest departure that counts, is a setting: Settings -> Thresholds -> Normals (norms.floor.*). Defaults: host CPU 10 points, host memory 5, load per core 10, container CPU 10 points of one core, container memory 5, monitor response 100 ms; Traefik's 2-point 4xx and 50 ms p95 floors move there from constants. learn_normal takes a floor function (floor_setting) read at each pass. - learn_normal(parts=False): a host learns, its cores don't. - Ingest records load_pct (load_1m / cores x 100), the figure pages grade. - Shared grading: anomalies.against / graded, worst_reading(normals=), hosts' host_grades, docker usage(thresholds=, norms=), monitors' latency_grade. Applied to host vitals, the Hosts widget, MCP overview, Docker usage, and the monitor list, widget and page. - The monitor and container pages list their anomalies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,7 +155,8 @@ def display_name(c) -> str:
|
||||
return c.name
|
||||
|
||||
|
||||
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None) -> dict:
|
||||
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None, *,
|
||||
thresholds: dict | None = None, norms: dict | None = None) -> dict:
|
||||
"""A container's CPU and memory in terms a reader can weigh.
|
||||
|
||||
Docker's CPU% is per core (100% = one core busy), so it is shown as cores
|
||||
@@ -164,11 +165,16 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
|
||||
is shown in bytes, against a limit only when one is actually set; mem_pct
|
||||
is then a share of that limit, otherwise of the host's memory. `cpu_pct`
|
||||
replaces the container's last sample (an average, say) when given.
|
||||
|
||||
With `thresholds`, cpu_grade and mem_grade are the grades to show: past
|
||||
the cutoff but within the container's own normal for the hour (`norms`,
|
||||
{metric: Norm}, container_norms) is "ok", and cpu_usual / mem_usual say
|
||||
what is usual (#5742). Without, they are None and the page grades.
|
||||
"""
|
||||
cpu = getattr(c, "cpu_pct", None) if cpu_pct is None else cpu_pct
|
||||
limit = getattr(c, "mem_limit_bytes", None)
|
||||
limited = bool(limit and mem_total and limit < mem_total * 0.95)
|
||||
return {
|
||||
out = {
|
||||
"cores": None if cpu is None else cpu / 100.0,
|
||||
"cpu_share": None if cpu is None or not cpus else cpu / cpus,
|
||||
"cpus": cpus,
|
||||
@@ -176,7 +182,51 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
|
||||
"mem_limit": limit if limited else None,
|
||||
"mem_pct": getattr(c, "mem_pct", None),
|
||||
"mem_of": "its limit" if limited else ("the host's memory" if mem_total else None),
|
||||
"cpu_grade": None, "cpu_usual": None, "mem_grade": None, "mem_usual": None,
|
||||
}
|
||||
if thresholds is not None:
|
||||
_grade(out, cpu, thresholds, norms or {})
|
||||
return out
|
||||
|
||||
|
||||
# Container figures graded against their own normal (#5742): the metric that
|
||||
# learns it and its floor's setting (norms.floor.*).
|
||||
NORMED = {"cpu": ("cpu_pct", "container_cpu_pct"), "mem": ("mem_pct", "container_mem_pct")}
|
||||
|
||||
|
||||
def _grade(u: dict, cpu: float | None, thresholds: dict, norms: dict) -> None:
|
||||
"""Fill usage()'s grades. CPU is graded as a share of the host but learned
|
||||
per core (Docker's own figure), so its normal is read in cores."""
|
||||
from steward.core.anomalies import departs, graded
|
||||
from steward.core.formatting import status_class
|
||||
from steward.core.settings import floor_of
|
||||
grade = status_class(u["cpu_share"], "cpu", thresholds)
|
||||
norm = norms.get("cpu_pct")
|
||||
if grade in ("warn", "crit") and norm is not None and cpu is not None \
|
||||
and not departs(cpu, norm, floor_of(thresholds, "container_cpu_pct")):
|
||||
grade = "ok"
|
||||
u["cpu_usual"] = f"usual {norm.usual_hold / 100:.2f} cores at this hour"
|
||||
u["cpu_grade"] = grade
|
||||
if u["mem_of"]:
|
||||
u["mem_grade"], u["mem_usual"] = graded(
|
||||
u["mem_pct"], "mem", thresholds, norms.get("mem_pct"),
|
||||
floor_of(thresholds, "container_mem_pct"), "%")
|
||||
else:
|
||||
u["mem_grade"] = "dim"
|
||||
|
||||
|
||||
async def container_norms(db, keys, now: datetime | None = None) -> dict:
|
||||
"""{(host_id, container_name): {metric: Norm}}: each container's CPU and
|
||||
memory normals for this hour. One without enough history is absent."""
|
||||
from steward.core.norms import norms_at
|
||||
resources = await container_resources(db, keys)
|
||||
got = await norms_at(db, [("docker", r, metric) for r in resources
|
||||
for metric, _f in NORMED.values()],
|
||||
now or datetime.now(timezone.utc))
|
||||
out: dict = {}
|
||||
for (_s, resource, metric), norm in got.items():
|
||||
out.setdefault(resources[resource], {})[metric] = norm
|
||||
return out
|
||||
|
||||
|
||||
async def host_capacity(db, host_ids) -> dict[str, tuple[int | None, int | None]]:
|
||||
|
||||
Reference in New Issue
Block a user