feat(host_agent): NFS delay per mount, read from /proc/self/mountstats (#5996)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m32s
CI / integration (push) Successful in 1m31s
CI / publish (push) Skipped

Each sample carries each NFS mount's requests since the last one: how many
a second, their round trip and execute time (READ and WRITE on their own),
and how many were sent again or hit a major timeout. mountstats is the
kernel's own count and never touches the share, so it reads while one is
stuck. Steward stores them per mount and the slowest on the host as
nfs_exe_max_ms, for an "NFS slow" rule and the host page's NFS chart beside
mounts_stalled; each mount's bar says what a request took.

Agent 1.22.0 (which also carries the backlog order from 41fc807).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 09:46:14 -04:00
co-authored by Claude Opus 5.5
parent 2df8431de9
commit 3f9aae7f46
12 changed files with 282 additions and 13 deletions
+25 -10
View File
@@ -25,17 +25,19 @@ def _const(node):
def _loop_names(func: ast.AST, var: str) -> set[str]:
"""Literal first elements of `for var, _ in [("a", x), ("b", y)]` loops."""
"""Literal elements bound to `var` by `for var, _ in [("a", x), ("b", y)]`
loops, wherever `var` sits in the target (`for _, var in ...` too)."""
names: set[str] = set()
for node in ast.walk(func):
if not (isinstance(node, ast.For) and isinstance(node.iter, (ast.List, ast.Tuple))):
continue
target = node.target
first = target.elts[0] if isinstance(target, ast.Tuple) and target.elts else target
if isinstance(first, ast.Name) and first.id == var:
for elt in node.iter.elts:
if isinstance(elt, ast.Tuple) and elt.elts and _const(elt.elts[0]):
names.add(elt.elts[0].value)
targets = node.target.elts if isinstance(node.target, ast.Tuple) else [node.target]
at = next((i for i, t in enumerate(targets) if isinstance(t, ast.Name) and t.id == var), None)
if at is None:
continue
for elt in node.iter.elts:
if isinstance(elt, ast.Tuple) and len(elt.elts) > at and _const(elt.elts[at]):
names.add(elt.elts[at].value)
return names
@@ -112,17 +114,30 @@ def host_agent_rows(source: str) -> set[str]:
for func in ast.walk(ast.parse(source)):
if isinstance(func, ast.FunctionDef) and func.name == "_expand_sample_to_metrics":
for call in ast.walk(func):
if (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id == "row" and call.args and _const(call.args[0])):
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id == "row" and call.args):
continue
if _const(call.args[0]):
names.add(call.args[0].value)
elif isinstance(call.args[0], ast.Name):
names |= _loop_names(func, call.args[0].id)
return names
def test_every_metric_host_agent_ingest_writes_is_in_the_catalog():
"""temp_c_max was recorded for weeks and no rule could watch it (#5787)."""
names = host_agent_rows((_REPO / "plugins/host_agent/routes.py").read_text())
assert {"cpu_pct", "temp_c_max", "mounts_stalled"} <= names and len(names) >= 20, names
assert {"cpu_pct", "temp_c_max", "mounts_stalled", "nfs_exe_ms"} <= names and len(names) >= 20, names
from plugins.host_agent import NOT_ALERTABLE
missing = sorted(names - catalog()["host_agent"] - NOT_ALERTABLE)
assert not missing, f"host_agent records these but no rule can watch them: {missing}"
assert NOT_ALERTABLE <= names, "NOT_ALERTABLE names something host_agent no longer records"
def test_a_loop_name_is_read_wherever_it_sits_in_the_tuple():
code = '''
def _expand_sample_to_metrics():
for key, metric in (("rtt_ms", "nfs_rtt_ms"), ("exe_ms", "nfs_exe_ms")):
row(metric, "h:/nfs", 1)
'''
assert host_agent_rows(code) == {"nfs_rtt_ms", "nfs_exe_ms"}