The snmp plugin registers list_snmp_devices and get_snmp_device; the prometheus plugin registers list_prometheus_targets and get_prometheus_target. Both are viewer-level, read what the poller and scraper stored, and exist only while their plugin is enabled. get_overview names a device or target whose last poll or scrape failed; stale, pending and off are left out. A device's community and v3 keys and a target's token are never returned, and a password in a target URL is masked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
5.5 KiB
Python
115 lines
5.5 KiB
Python
"""The prometheus plugin's MCP tools and its get_overview findings (#5840).
|
|
|
|
Registered through register_mcp_tools() in __init__.py, so they exist only
|
|
while the plugin is enabled. They read what the scraper stored; nothing
|
|
reaches a target, and a target's bearer token (or a password in its URL)
|
|
is never returned.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
|
|
from steward.core.metric_series import latest_values
|
|
from steward.mcp.tools._shape import age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url
|
|
from steward.mcp.tools.overview import finding
|
|
from steward.models.metrics import PluginMetricLatest
|
|
from steward.models.users import UserRole
|
|
|
|
from .models import PrometheusTarget
|
|
from .routes import group_series
|
|
from .service import OWN_METRICS, SOURCE, target_state
|
|
|
|
REACHES = ("down", "stale", "pending", "up", "off")
|
|
_ORDER = {r: i for i, r in enumerate(REACHES)}
|
|
|
|
|
|
def _target_view(t: PrometheusTarget, own: dict, now) -> dict:
|
|
state = target_state(t, now)
|
|
return {"target_id": t.id, "name": t.name, "url": redact_url(t.url), "enabled": t.enabled,
|
|
"reach": state.reach, "says": state.says, "interval_s": t.interval_seconds,
|
|
"scraped_at": iso(t.last_scraped_at), "ok_at": iso(t.last_ok_at), "ok_age_s": age_s(t.last_ok_at, now),
|
|
"series_kept": t.last_kept, "series_dropped": t.last_dropped, "series_capped": t.last_capped,
|
|
"series_cap": t.series_cap, "keep": list(t.keep or []), "drop": list(t.drop or []),
|
|
"scrape_ms": own.get("scrape_ms")}
|
|
|
|
|
|
async def list_prometheus_targets() -> dict:
|
|
"""The /metrics endpoints Steward scrapes, not answering first.
|
|
|
|
`reach`: "down" (the last scrape failed; `says` is why), "stale" (not
|
|
scraped recently), "pending", "up" or "off". `series_kept` /
|
|
`series_dropped` count the newest scrape's series the keep/drop patterns
|
|
let through or left out; `series_capped` those past `series_cap`, which
|
|
were not stored. get_prometheus_target lists a target's series;
|
|
query_metrics(source_module="prometheus", resource=…, metric=…) reads
|
|
one's history.
|
|
"""
|
|
now = now_utc()
|
|
async with db_session() as db:
|
|
targets = list((await db.execute(select(PrometheusTarget).order_by(PrometheusTarget.name))).scalars())
|
|
rows = (await db.execute(select(PluginMetricLatest).where(
|
|
PluginMetricLatest.source_module == SOURCE,
|
|
PluginMetricLatest.resource_name.in_([t.name for t in targets] or [""]),
|
|
PluginMetricLatest.metric_name == "scrape_ms"))).scalars()
|
|
own = {r.resource_name: {"scrape_ms": r.value} for r in rows}
|
|
views = [_target_view(t, own.get(t.name, {}), now) for t in targets]
|
|
views.sort(key=lambda v: (_ORDER[v["reach"]], v["name"].lower()))
|
|
return {"targets": views}
|
|
|
|
|
|
async def get_prometheus_target(target: str, search: str = "", limit: int = 100) -> dict:
|
|
"""One target's stored series, grouped by metric name, each with the
|
|
labels that tell its series apart and the newest value in its unit.
|
|
|
|
target: its name or id (list_prometheus_targets).
|
|
search: part of a metric name; empty for all.
|
|
limit: metric groups to return, default 100, max 500.
|
|
|
|
A state set (a series per state, one of them 1) is folded to the
|
|
current state. `resource` is what query_metrics takes for a series'
|
|
history (source_module "prometheus").
|
|
"""
|
|
now = now_utc()
|
|
async with db_session() as db:
|
|
t = pick(target, list((await db.execute(select(PrometheusTarget))).scalars()),
|
|
kind="Prometheus target", list_tool="list_prometheus_targets")
|
|
latest = await latest_values(db, SOURCE, t.name, include_children=True)
|
|
own = {r.metric_name: r.value for r in latest if r.resource_name == t.name and r.metric_name in OWN_METRICS}
|
|
groups = group_series(t.name, latest)
|
|
if search:
|
|
groups = [g for g in groups if search.lower() in g["metric"].lower()]
|
|
|
|
def row(r: dict, states: bool) -> dict:
|
|
if states:
|
|
return {"labels": r["labels"], "resource": r["resource"], "state": r["text"], "states": r["states"]}
|
|
return {"labels": r["labels"], "resource": r["resource"], "value": r["value"],
|
|
"shown": r["text"] or iso(r["moment"]), "at": iso(r["at"])}
|
|
|
|
shaped = [{"metric": g["metric"], "series": [row(r, g["states"]) for r in g["rows"][:50]],
|
|
**({"more_series": len(g["rows"]) - 50} if len(g["rows"]) > 50 else {})} for g in groups]
|
|
out = {"target": _target_view(t, own, now)}
|
|
out.update(bounded(shaped, clamp_limit(limit, 100, 500), key="metrics",
|
|
more="Pass a `search`, or a larger `limit`."))
|
|
return out
|
|
|
|
|
|
def target_findings(views: list[dict]) -> list[dict]:
|
|
"""A target whose last scrape failed. Stale and off are the plugin or
|
|
the admin's choice, not the endpoint, so they are left out."""
|
|
return [finding("warning", "prometheus_target_down", v["name"],
|
|
f"Prometheus target {v['name']} isn't answering: {v['says']}",
|
|
since=v["_ok_at"], ids={"target_id": v["target_id"]},
|
|
next=f'get_prometheus_target(target="{v["name"]}")')
|
|
for v in views if v["reach"] == "down"]
|
|
|
|
|
|
async def mcp_overview(db) -> list[dict]:
|
|
now = now_utc()
|
|
targets = list((await db.execute(select(PrometheusTarget))).scalars())
|
|
return target_findings([_target_view(t, {}, now) | {"_ok_at": t.last_ok_at} for t in targets])
|
|
|
|
|
|
def register_tools(register) -> None:
|
|
for fn in (list_prometheus_targets, get_prometheus_target):
|
|
register(fn, role=UserRole.viewer, read_only=True)
|