"""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)