File indexing completed on 2026-09-01 09:34:26
0001 """Publish the epicprod PanDA platform-health component to Snapper.
0002
0003 Design: docs/SNAPPER_PLATFORM.md. Each publication carries the owner's
0004 assessed reading of the platform at one instant, in five groups:
0005
0006 - database: PanDA database connections, longest transaction, and the
0007 jobsactive4 table's health, read from the PanDA database;
0008 - heartbeats: running jobs by heartbeat age, heartbeats received and
0009 jobs started in the publication interval, the heartbeat yield
0010 against the running population's expected rate, and the same yield
0011 as a ratio of sums over a window of two heartbeat periods (the
0012 per-interval yield beats against the pilot's heartbeat phase; the
0013 window is the assessed figure);
0014 - server: one timed liveness request to the PanDA server;
0015 - server_host: the pandaserver01 reporter's record when one has been
0016 delivered (docs/PANDA_SERVER_REPORTER.md), else absent;
0017 - monitor_host: the monitor's own host and tier, measured locally.
0018
0019 Load (jobs in flight, cores) and consequences (kills, outcomes) are
0020 not recorded here: the PanDA activity and error-state components
0021 carry them at the same cadence, and the Platform view reads them from
0022 there. Every measurement that fails records its failure in place and
0023 the publication proceeds; a source that cannot be read is an assessed
0024 unavailable state, never a silent omission.
0025 """
0026
0027 import json
0028 import os
0029 import subprocess
0030 import time
0031 from dataclasses import dataclass
0032 import logging
0033 from datetime import datetime, timedelta
0034 from datetime import timezone as dt_timezone
0035
0036 from django.db import connection, connections, transaction
0037 from django.utils import timezone
0038
0039 logger = logging.getLogger(__name__)
0040
0041 from snapper_ai.services import (
0042 ComponentUpdate,
0043 publish_component,
0044 register_component,
0045 )
0046
0047 from .panda.constants import PANDA_SCHEMA
0048 from .snapper_panda import _bounded_site_names, _canary_queue_names
0049
0050 PUBLISHER_IDENTITY = "swf-monitor:panda-platform"
0051 ASSESSMENT_POLICY_VERSION = "swf-panda-platform-v1"
0052 COMPONENT_NAME = "platform"
0053 SCOPE = "epicprod"
0054
0055 MAX_SITES = 32
0056 MAX_APPS = 16
0057 MAX_SERIALIZED_BYTES = 64 * 1024
0058
0059
0060 STALENESS_TIERS_MINUTES = (30, 60, 120)
0061
0062
0063 FIRST_INTERVAL_MINUTES = 5
0064
0065 MONITOR_UNITS = {
0066 "asgi": "swf-monitor-mcp-asgi",
0067 "ops_agent": "epicprod-ops-agent",
0068 "httpd": "httpd",
0069 }
0070
0071
0072
0073 CONFIG_DEFAULTS = {
0074 "platform_panda_server_url": "https://pandaserver01.sdcc.bnl.gov:25443",
0075 "platform_server_timeout_seconds": 10,
0076 "platform_pandamon_url": "https://pandamon01.sdcc.bnl.gov",
0077 "platform_pandamon_timeout_seconds": 20,
0078 "platform_heartbeat_period_seconds": 1800,
0079 "platform_monitor_volumes": ["/", "/var", "/data"],
0080 "platform_reporter_stale_seconds": 900,
0081
0082 "platform_yield_warn_below": 0.5,
0083 "platform_connections_warn_fraction": 0.8,
0084 "platform_latency_warn_ms": 2000,
0085 "platform_pandamon_warn_ms": 20000,
0086 "platform_stale_warn_fraction": 0.25,
0087 "platform_stale_warn_tier_minutes": 60,
0088 "platform_volume_warn_percent": 90,
0089 }
0090
0091 PLATFORM_REGISTRATION = {
0092 "title": "PanDA platform health",
0093 "description": (
0094 "Five-minute assessed readings of the PanDA platform: database "
0095 "connections and table health, running-job heartbeat ages and "
0096 "the heartbeat yield over the publication interval, PanDA "
0097 "server liveness latency, the server host's own report when "
0098 "delivered, and the monitor host's tier. Load and consequences "
0099 "are recorded by the PanDA activity and error-state components."
0100 ),
0101 "visibility": "public",
0102 "owning_subsystem": "SWF PanDA production monitor",
0103 "assessment_policy": ASSESSMENT_POLICY_VERSION,
0104 "max_serialized_bytes": MAX_SERIALIZED_BYTES,
0105 "quantities": {
0106 "interval": {
0107 "path": "interval",
0108 "type": "object",
0109 "required": True,
0110 "kind": "window",
0111 "description": (
0112 "The half-open interval (start, end] the interval "
0113 "quantities (heartbeats received, starts) cover; runs "
0114 "from the previous publication's source time."
0115 ),
0116 },
0117 "database": {
0118 "path": "database",
0119 "type": "object",
0120 "required": True,
0121 "kind": "gauge",
0122 "description": (
0123 "PanDA database: connections total/active/idle/waiting "
0124 "against max_connections, the longest open transaction "
0125 "in seconds, jobsactive4 live and dead tuples and minutes "
0126 "since its last autovacuum, and connections by "
0127 "application and state (bounded map, remainder in "
0128 "'other'). An 'error' key records a failed read."
0129 ),
0130 },
0131 "database_by_app": {
0132 "path": "database.by_app",
0133 "type": "object",
0134 "required": False,
0135 "kind": "bounded_map",
0136 "max_items": MAX_APPS,
0137 "description": "Connections by application name and state.",
0138 },
0139 "heartbeats": {
0140 "path": "heartbeats",
0141 "type": "object",
0142 "required": True,
0143 "kind": "assessment",
0144 "description": (
0145 "Running jobs by heartbeat age (stale_30, stale_60, "
0146 "stale_120: last modification older than N minutes), "
0147 "heartbeats received and jobs started in the interval, "
0148 "the expected heartbeat count for the running population "
0149 "at the configured heartbeat period, the yield "
0150 "(received over expected), and window: the yield as a "
0151 "ratio of sums over the last two heartbeat periods "
0152 "(seconds, intervals, received, expected, yield, and the "
0153 "recent intervals it sums). The window yield is the "
0154 "assessed figure; the per-interval yield beats against "
0155 "the pilot heartbeat phase."
0156 ),
0157 },
0158 "heartbeat_sites": {
0159 "path": "heartbeats.sites",
0160 "type": "object",
0161 "required": True,
0162 "kind": "bounded_map",
0163 "max_items": MAX_SITES,
0164 "description": (
0165 "Per-site running counts and heartbeat-age tiers. Every "
0166 "non-test Canary queue is retained; remaining slots take "
0167 "the sites with the most running jobs."
0168 ),
0169 },
0170 "server": {
0171 "path": "server",
0172 "type": "object",
0173 "required": True,
0174 "kind": "gauge",
0175 "description": (
0176 "One timed liveness request to the PanDA server: "
0177 "latency in milliseconds, HTTP status, and ok; a timeout "
0178 "or failure is recorded with its reason."
0179 ),
0180 },
0181 "pandamon": {
0182 "path": "pandamon",
0183 "type": "object",
0184 "required": False,
0185 "kind": "gauge",
0186 "description": (
0187 "Two timed requests to the PanDA monitor (BigPanDA) web "
0188 "face: its front page and the harvester worker-stats "
0189 "query the monitor's tools use; latency in milliseconds, "
0190 "HTTP status, and ok; a timeout or failure is recorded "
0191 "with its reason."
0192 ),
0193 },
0194 "server_host": {
0195 "path": "server_host",
0196 "type": "object",
0197 "required": False,
0198 "kind": "gauge",
0199 "description": (
0200 "The pandaserver01 reporter's latest record (web-tier "
0201 "request counts, daemon liveness, WSGI tier, host "
0202 "resources), present only when one has been delivered."
0203 ),
0204 },
0205 "reporter_status": {
0206 "path": "reporter_status",
0207 "type": "string",
0208 "required": True,
0209 "kind": "assessment",
0210 "enum": ["fresh", "stale", "absent"],
0211 "description": (
0212 "Freshness of the server-host report against the "
0213 "configured staleness threshold."
0214 ),
0215 },
0216 "monitor_host": {
0217 "path": "monitor_host",
0218 "type": "object",
0219 "required": True,
0220 "kind": "gauge",
0221 "description": (
0222 "The monitor host: load average, memory and swap, volume "
0223 "use, the WSGI daemon and httpd processes, the ASGI and "
0224 "prod-ops agent services, and the monitor database's "
0225 "connection count."
0226 ),
0227 },
0228 "assessment": {
0229 "path": "assessment",
0230 "type": "object",
0231 "required": True,
0232 "kind": "assessment",
0233 "description": (
0234 "Per-metric verdicts (ok, warning, unknown) against the "
0235 "SysConfig thresholds, the thresholds applied, and the "
0236 "overall verdict."
0237 ),
0238 },
0239 },
0240 }
0241
0242
0243 @dataclass(frozen=True)
0244 class PlatformPublication:
0245 registration_update: ComponentUpdate
0246 update: ComponentUpdate
0247 projection: dict
0248 observed_at: datetime
0249
0250
0251 def _config(key):
0252 from .models import SysConfig
0253
0254 return SysConfig.get_setting(key, CONFIG_DEFAULTS[key])
0255
0256
0257 def _iso_utc(value):
0258 if value.tzinfo is not None:
0259 value = value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0260 return value.isoformat(timespec="seconds") + "Z"
0261
0262
0263 def _naive_utc(value):
0264 """The PanDA database stores naive UTC timestamps."""
0265 return value.astimezone(dt_timezone.utc).replace(tzinfo=None)
0266
0267
0268
0269
0270 def database_reading():
0271 """Connections, longest transaction, and jobsactive4 health from
0272 the PanDA database's own statistics views."""
0273 out = {}
0274 try:
0275 with connections["panda"].cursor() as cursor:
0276 cursor.execute(
0277 "SELECT setting::int FROM pg_settings "
0278 "WHERE name = 'max_connections'")
0279 out["max_connections"] = int(cursor.fetchone()[0])
0280 cursor.execute(
0281 "SELECT COALESCE(state, ''), "
0282 " (wait_event IS NOT NULL AND state = 'active'), "
0283 " COUNT(*) "
0284 "FROM pg_stat_activity WHERE datname = current_database() "
0285 "GROUP BY 1, 2")
0286 total = active = idle = waiting = 0
0287 for state, is_waiting, count in cursor.fetchall():
0288 count = int(count or 0)
0289 total += count
0290 if state == "active":
0291 active += count
0292 if is_waiting:
0293 waiting += count
0294 elif state.startswith("idle"):
0295 idle += count
0296 out.update({"connections": total, "active": active,
0297 "idle": idle, "waiting": waiting})
0298 cursor.execute(
0299 "SELECT COALESCE(EXTRACT(EPOCH FROM MAX(now() - xact_start)), 0) "
0300 "FROM pg_stat_activity "
0301 "WHERE datname = current_database() AND state <> 'idle'")
0302 out["longest_transaction_s"] = round(float(cursor.fetchone()[0] or 0), 1)
0303 cursor.execute(
0304 "SELECT n_live_tup, n_dead_tup, last_autovacuum "
0305 "FROM pg_stat_user_tables "
0306 "WHERE schemaname = %s AND relname = 'jobsactive4'",
0307 [PANDA_SCHEMA])
0308 row = cursor.fetchone()
0309 if row:
0310 live, dead, last_vacuum = row
0311 out["jobsactive4"] = {
0312 "live_tuples": int(live or 0),
0313 "dead_tuples": int(dead or 0),
0314 "minutes_since_autovacuum": (
0315 round((timezone.now() - last_vacuum).total_seconds() / 60)
0316 if last_vacuum is not None else None),
0317 }
0318 cursor.execute(
0319 "SELECT COALESCE(NULLIF(application_name, ''), 'unnamed'), "
0320 " COALESCE(state, ''), COUNT(*) "
0321 "FROM pg_stat_activity WHERE datname = current_database() "
0322 "GROUP BY 1, 2 ORDER BY 3 DESC")
0323 by_app = {}
0324 for app, state, count in cursor.fetchall():
0325 key = f"{app}@{state}" if state else str(app)
0326 by_app[key] = int(count or 0)
0327 if len(by_app) > MAX_APPS:
0328 keep = dict(list(by_app.items())[:MAX_APPS - 1])
0329 keep["other"] = sum(list(by_app.values())[MAX_APPS - 1:])
0330 by_app = keep
0331 out["by_app"] = by_app
0332 except Exception as e:
0333 out["error"] = f"{type(e).__name__}: {e}"[:300]
0334 return out
0335
0336
0337
0338
0339 def heartbeat_reading(mark, until, period_seconds):
0340 """Running jobs by heartbeat age (scope and per site), heartbeats
0341 received and jobs started in (mark, until], and the yield against
0342 the running population's expected heartbeat count."""
0343 out = {"tiers_minutes": list(STALENESS_TIERS_MINUTES)}
0344 until_naive = _naive_utc(until)
0345 mark_naive = _naive_utc(mark)
0346 tier_filters = ", ".join(
0347 f"COUNT(*) FILTER (WHERE \"modificationtime\" < %s)"
0348 for _ in STALENESS_TIERS_MINUTES)
0349 tier_params = [until_naive - timedelta(minutes=m)
0350 for m in STALENESS_TIERS_MINUTES]
0351 try:
0352 with connections["panda"].cursor() as cursor:
0353 cursor.execute(
0354 f"SELECT COALESCE(\"computingsite\", 'unknown'), COUNT(*), "
0355 f" {tier_filters}, "
0356 f" COUNT(*) FILTER (WHERE \"modificationtime\" > %s) "
0357 f"FROM \"{PANDA_SCHEMA}\".\"jobsactive4\" "
0358 f"WHERE \"jobstatus\" = 'running' "
0359 f"GROUP BY 1",
0360 tier_params + [mark_naive])
0361 per_site = {}
0362 running = received = 0
0363 stale = [0] * len(STALENESS_TIERS_MINUTES)
0364 for row in cursor.fetchall():
0365 site = str(row[0])
0366 count = int(row[1] or 0)
0367 tiers = [int(v or 0) for v in row[2:2 + len(STALENESS_TIERS_MINUTES)]]
0368 got = int(row[2 + len(STALENESS_TIERS_MINUTES)] or 0)
0369 running += count
0370 received += got
0371 stale = [a + b for a, b in zip(stale, tiers)]
0372 entry = {"running": count, "received": got}
0373 for minutes, value in zip(STALENESS_TIERS_MINUTES, tiers):
0374 entry[f"stale_{minutes}"] = value
0375 per_site[site] = entry
0376 cursor.execute(
0377 f"SELECT COUNT(*) FROM ("
0378 f" SELECT \"pandaid\" FROM \"{PANDA_SCHEMA}\".\"jobsactive4\" "
0379 f" WHERE \"starttime\" > %s AND \"starttime\" <= %s "
0380 f" UNION "
0381 f" SELECT \"pandaid\" FROM \"{PANDA_SCHEMA}\".\"jobsarchived4\" "
0382 f" WHERE \"starttime\" > %s AND \"starttime\" <= %s) s",
0383 [mark_naive, until_naive, mark_naive, until_naive])
0384 started = int(cursor.fetchone()[0] or 0)
0385 except Exception as e:
0386 out["error"] = f"{type(e).__name__}: {e}"[:300]
0387 out.update({"running": 0, "received": 0, "started": 0,
0388 "expected": 0, "yield": None, "sites": {}})
0389 for minutes in STALENESS_TIERS_MINUTES:
0390 out[f"stale_{minutes}"] = 0
0391 return out
0392
0393 interval_seconds = max(0.0, (until - mark).total_seconds())
0394 expected = round(running * interval_seconds / max(period_seconds, 1))
0395 out.update({
0396 "running": running,
0397 "received": received,
0398 "started": started,
0399 "expected": expected,
0400 "yield": (round(min(received / expected, 9.999), 3)
0401 if expected else None),
0402 "period_seconds": int(period_seconds),
0403 })
0404 for minutes, value in zip(STALENESS_TIERS_MINUTES, stale):
0405 out[f"stale_{minutes}"] = value
0406 catalog = _canary_queue_names()
0407 names = _bounded_site_names(
0408 per_site, catalog,
0409 lambda name: (-int((per_site.get(name) or {}).get("running") or 0),
0410 name))
0411 sites = {}
0412 for name in names:
0413 entry = per_site.get(name)
0414 if entry is None:
0415 entry = {"running": 0, "received": 0}
0416 for minutes in STALENESS_TIERS_MINUTES:
0417 entry[f"stale_{minutes}"] = 0
0418 sites[name] = entry
0419 out["sites"] = sites
0420 return out
0421
0422
0423
0424
0425 def timed_get(url, timeout_seconds, params=None):
0426 """One timed GET: latency in milliseconds, HTTP status, ok; a
0427 timeout records at the timeout value with its reason, never omitted."""
0428 import requests
0429
0430 started = time.monotonic()
0431 try:
0432 response = requests.get(url, params=params,
0433 timeout=float(timeout_seconds))
0434 ms = round((time.monotonic() - started) * 1000, 1)
0435 return {"url": url, "latency_ms": ms,
0436 "status": int(response.status_code),
0437 "ok": response.status_code == 200,
0438 "timeout": False}
0439 except requests.Timeout:
0440 return {"url": url, "latency_ms": round(float(timeout_seconds) * 1000, 1),
0441 "status": None, "ok": False, "timeout": True,
0442 "error": f"no response within {timeout_seconds}s"}
0443 except Exception as e:
0444 return {"url": url,
0445 "latency_ms": round((time.monotonic() - started) * 1000, 1),
0446 "status": None, "ok": False, "timeout": False,
0447 "error": f"{type(e).__name__}: {e}"[:300]}
0448
0449
0450 def server_reading(base_url, timeout_seconds):
0451 """One timed request to the PanDA server's liveness endpoint."""
0452 return timed_get(f"{base_url.rstrip('/')}/api/v1/system/is_alive",
0453 timeout_seconds)
0454
0455
0456 def pandamon_reading(base_url, timeout_seconds, now):
0457 """Two timed requests to the PanDA monitor (BigPanDA) web face: the
0458 front page, and the harvester worker-stats query over the last hour
0459 — the request the monitor's own tools make, so the record shows the
0460 face as its consumers meet it."""
0461 from concurrent.futures import ThreadPoolExecutor
0462
0463 root = base_url.rstrip("/")
0464 since = now - timedelta(hours=1)
0465
0466
0467 with ThreadPoolExecutor(max_workers=2) as pool:
0468 front = pool.submit(timed_get, f"{root}/", timeout_seconds)
0469 workers = pool.submit(
0470 timed_get, f"{root}/harvester/getworkerstats/", timeout_seconds,
0471 {"lastupdate_from": since.strftime("%Y-%m-%d %H:%M:%S"),
0472 "lastupdate_to": now.strftime("%Y-%m-%d %H:%M:%S")})
0473 return {"front": front.result(), "workers": workers.result()}
0474
0475
0476
0477
0478 def server_host_reading(now, stale_seconds):
0479 """The pandaserver01 reporter's latest record and its freshness.
0480 Until the reporter and its ingest exist (docs/SNAPPER_PLATFORM.md,
0481 delivery stage 3) there is no record: status 'absent'."""
0482 return None, "absent"
0483
0484
0485
0486
0487 def _rss_kb(pid):
0488 try:
0489 with open(f"/proc/{pid}/status") as handle:
0490 for line in handle:
0491 if line.startswith("VmRSS:"):
0492 return int(line.split()[1])
0493 except OSError:
0494 return None
0495 return None
0496
0497
0498 def _unit_state(unit):
0499 try:
0500 result = subprocess.run(
0501 ["systemctl", "show", unit,
0502 "-p", "ActiveState,MainPID,NRestarts"],
0503 capture_output=True, text=True, timeout=10, check=False)
0504 except (OSError, subprocess.SubprocessError) as e:
0505 return {"error": f"{type(e).__name__}: {e}"[:200]}
0506 props = dict(line.split("=", 1) for line in result.stdout.splitlines()
0507 if "=" in line)
0508 pid = int(props.get("MainPID") or 0)
0509 rss = _rss_kb(pid) if pid else None
0510 return {"active": props.get("ActiveState") == "active",
0511 "state": props.get("ActiveState") or "unknown",
0512 "restarts": int(props.get("NRestarts") or 0),
0513 "rss_mb": round(rss / 1024, 1) if rss is not None else None}
0514
0515
0516 def _process_scan():
0517 """httpd process count and resident memory, and the mod_wsgi
0518 daemon processes (display name 'wsgi:...') separately."""
0519 httpd = {"count": 0, "rss_mb": 0.0}
0520 wsgi = {"count": 0, "rss_mb": 0.0}
0521 for entry in os.listdir("/proc"):
0522 if not entry.isdigit():
0523 continue
0524 try:
0525 with open(f"/proc/{entry}/comm") as handle:
0526 comm = handle.read().strip()
0527 if comm != "httpd":
0528 continue
0529 with open(f"/proc/{entry}/cmdline", "rb") as handle:
0530 cmdline = handle.read().replace(b"\0", b" ").decode(
0531 "utf-8", "replace")
0532 except OSError:
0533 continue
0534 rss = _rss_kb(entry) or 0
0535 httpd["count"] += 1
0536 httpd["rss_mb"] += rss / 1024
0537 if "wsgi:" in cmdline:
0538 wsgi["count"] += 1
0539 wsgi["rss_mb"] += rss / 1024
0540 httpd["rss_mb"] = round(httpd["rss_mb"], 1)
0541 wsgi["rss_mb"] = round(wsgi["rss_mb"], 1)
0542 return httpd, wsgi
0543
0544
0545 def monitor_host_reading(volumes):
0546 out = {}
0547 try:
0548 with open("/proc/loadavg") as handle:
0549 parts = handle.read().split()
0550 out["load"] = {"1m": float(parts[0]), "5m": float(parts[1]),
0551 "15m": float(parts[2])}
0552 out["cpus"] = os.cpu_count()
0553 except (OSError, ValueError, IndexError) as e:
0554 out["load_error"] = f"{type(e).__name__}: {e}"[:200]
0555 try:
0556 info = {}
0557 with open("/proc/meminfo") as handle:
0558 for line in handle:
0559 key, _, rest = line.partition(":")
0560 info[key] = int(rest.split()[0])
0561 total = info["MemTotal"]
0562 available = info["MemAvailable"]
0563 out["memory"] = {
0564 "total_mb": round(total / 1024),
0565 "available_mb": round(available / 1024),
0566 "used_mb": round((total - available) / 1024),
0567 "used_percent": round(100 * (total - available) / total, 1),
0568 "swap_used_mb": round((info["SwapTotal"] - info["SwapFree"]) / 1024),
0569 }
0570 except (OSError, ValueError, KeyError) as e:
0571 out["memory_error"] = f"{type(e).__name__}: {e}"[:200]
0572 vols = {}
0573 for path in volumes:
0574 try:
0575 stat = os.statvfs(path)
0576 total = stat.f_blocks * stat.f_frsize
0577 free = stat.f_bavail * stat.f_frsize
0578 vols[path] = {"used_percent": round(100 * (total - free) / total, 1)
0579 if total else None,
0580 "free_gb": round(free / 1e9, 1)}
0581 except OSError as e:
0582 vols[path] = {"error": f"{type(e).__name__}: {e}"[:200]}
0583 out["volumes"] = vols
0584 try:
0585 httpd, wsgi = _process_scan()
0586 out["httpd"] = httpd
0587 out["wsgi"] = wsgi
0588 except OSError as e:
0589 out["process_error"] = f"{type(e).__name__}: {e}"[:200]
0590 out["asgi"] = _unit_state(MONITOR_UNITS["asgi"])
0591 out["ops_agent"] = _unit_state(MONITOR_UNITS["ops_agent"])
0592 try:
0593 with connection.cursor() as cursor:
0594 cursor.execute(
0595 "SELECT COUNT(*) FROM pg_stat_activity "
0596 "WHERE datname = current_database()")
0597 out["db_connections"] = int(cursor.fetchone()[0] or 0)
0598 except Exception as e:
0599 out["db_error"] = f"{type(e).__name__}: {e}"[:200]
0600 return out
0601
0602
0603
0604
0605 def assess(database, heartbeats, server, reporter_status, monitor_host,
0606 thresholds, pandamon=None):
0607 """Per-metric verdicts against the configured thresholds."""
0608 verdicts = {}
0609
0610 def verdict(name, condition_warning, known=True):
0611 verdicts[name] = ("unknown" if not known
0612 else "warning" if condition_warning else "ok")
0613
0614
0615
0616 y = (heartbeats.get("window") or {}).get("yield")
0617 if y is None:
0618 y = heartbeats.get("yield")
0619 verdict("heartbeat_yield",
0620 y is not None and y < thresholds["platform_yield_warn_below"],
0621 known=y is not None and "error" not in heartbeats)
0622 running = int(heartbeats.get("running") or 0)
0623 tier = int(thresholds["platform_stale_warn_tier_minutes"])
0624 stale = int(heartbeats.get(f"stale_{tier}") or 0)
0625 verdict("heartbeat_staleness",
0626 running > 0 and stale / running
0627 > thresholds["platform_stale_warn_fraction"],
0628 known="error" not in heartbeats)
0629 conns = database.get("connections")
0630 limit = database.get("max_connections")
0631 verdict("db_connections",
0632 bool(conns and limit) and conns / limit
0633 > thresholds["platform_connections_warn_fraction"],
0634 known="error" not in database and bool(limit))
0635 verdict("server_latency",
0636 (not server.get("ok"))
0637 or server.get("latency_ms", 0) > thresholds["platform_latency_warn_ms"],
0638 known=True)
0639 probes = list((pandamon or {}).values())
0640 verdict("pandamon_latency",
0641 any((not p.get("ok"))
0642 or p.get("latency_ms", 0) > thresholds["platform_pandamon_warn_ms"]
0643 for p in probes),
0644 known=bool(probes))
0645 vols = (monitor_host.get("volumes") or {})
0646 percents = [v.get("used_percent") for v in vols.values()
0647 if isinstance(v, dict) and v.get("used_percent") is not None]
0648 verdict("monitor_volumes",
0649 any(p > thresholds["platform_volume_warn_percent"] for p in percents),
0650 known=bool(percents))
0651 verdict("monitor_services",
0652 not ((monitor_host.get("asgi") or {}).get("active")
0653 and (monitor_host.get("ops_agent") or {}).get("active")),
0654 known=True)
0655 verdicts["reporter"] = ("ok" if reporter_status == "fresh"
0656 else "warning" if reporter_status == "stale"
0657 else "unknown")
0658 if any(v == "warning" for v in verdicts.values()):
0659 overall = "warning"
0660 elif all(v == "ok" for v in verdicts.values()):
0661 overall = "ok"
0662 else:
0663 overall = "ok" if any(v == "ok" for v in verdicts.values()) else "unknown"
0664 return {"overall": overall, "verdicts": verdicts,
0665 "thresholds": thresholds}
0666
0667
0668
0669
0670 def _previous_publication():
0671 """The component's current source time and data (None, None for a
0672 fresh record)."""
0673 from snapper_ai.models import CurrentComponent
0674
0675 row = (CurrentComponent.objects
0676 .filter(scope=SCOPE, name=COMPONENT_NAME)
0677 .values("source_as_of", "data").first())
0678 if not row:
0679 return None, None
0680 return row["source_as_of"], (row["data"] or {})
0681
0682
0683 WINDOW_PERIODS = 2
0684 WINDOW_MAX_INTERVALS = 48
0685
0686
0687 def heartbeat_window(heartbeats, previous, mark, until, period_seconds):
0688 """The yield over the last WINDOW_PERIODS heartbeat periods as a
0689 ratio of sums: the intervals carried by the previous publication
0690 that end inside the window, plus this one. A ratio of sums, never
0691 a mean of ratios — the running population moves between
0692 intervals."""
0693 window_seconds = WINDOW_PERIODS * max(int(period_seconds), 1)
0694 floor = until - timedelta(seconds=window_seconds)
0695 recent = []
0696 for entry in ((previous or {}).get("window") or {}).get("recent") or []:
0697 try:
0698 end = datetime.fromisoformat(
0699 str(entry["end"]).replace("Z", "+00:00"))
0700 except (KeyError, ValueError, TypeError) as e:
0701 logger.error("platform heartbeat window: bad recent entry "
0702 "%r: %s", entry, e)
0703 continue
0704 if end > floor and end <= mark:
0705 recent.append({"start": entry.get("start"), "end": entry["end"],
0706 "received": int(entry.get("received") or 0),
0707 "expected": int(entry.get("expected") or 0)})
0708 if "error" not in heartbeats:
0709 recent.append({"start": _iso_utc(mark), "end": _iso_utc(until),
0710 "received": int(heartbeats.get("received") or 0),
0711 "expected": int(heartbeats.get("expected") or 0)})
0712 recent = recent[-WINDOW_MAX_INTERVALS:]
0713 received = sum(e["received"] for e in recent)
0714 expected = sum(e["expected"] for e in recent)
0715 return {
0716 "seconds": window_seconds,
0717 "periods": WINDOW_PERIODS,
0718 "intervals": len(recent),
0719 "received": received,
0720 "expected": expected,
0721 "yield": (round(min(received / expected, 9.999), 3)
0722 if expected else None),
0723 "recent": recent,
0724 }
0725
0726
0727 def platform_projection(now=None, mark=None):
0728 """Build the platform projection without publishing. The interval
0729 is (mark, now]; mark defaults to the component's current source
0730 time, or a short first interval for a fresh record."""
0731 observed_at = now or timezone.now()
0732 previous_mark, previous = _previous_publication()
0733 if mark is None:
0734 mark = previous_mark
0735 if mark is None or mark >= observed_at:
0736 mark = observed_at - timedelta(minutes=FIRST_INTERVAL_MINUTES)
0737 thresholds = {key: _config(key) for key in CONFIG_DEFAULTS
0738 if key.startswith("platform_") and (
0739 "warn" in key)}
0740 database = database_reading()
0741 period_seconds = int(_config("platform_heartbeat_period_seconds"))
0742 heartbeats = heartbeat_reading(mark, observed_at, period_seconds)
0743 heartbeats["window"] = heartbeat_window(
0744 heartbeats, previous, mark, observed_at, period_seconds)
0745 server = server_reading(str(_config("platform_panda_server_url")),
0746 _config("platform_server_timeout_seconds"))
0747 pandamon = pandamon_reading(str(_config("platform_pandamon_url")),
0748 _config("platform_pandamon_timeout_seconds"),
0749 observed_at)
0750 server_host, reporter_status = server_host_reading(
0751 observed_at, int(_config("platform_reporter_stale_seconds")))
0752 monitor_host = monitor_host_reading(
0753 list(_config("platform_monitor_volumes") or []))
0754 projection = {
0755 "interval": {"start": _iso_utc(mark), "end": _iso_utc(observed_at)},
0756 "database": database,
0757 "heartbeats": heartbeats,
0758 "server": server,
0759 "pandamon": pandamon,
0760 "reporter_status": reporter_status,
0761 "monitor_host": monitor_host,
0762 "assessment": assess(database, heartbeats, server, reporter_status,
0763 monitor_host, thresholds, pandamon),
0764 }
0765 if server_host is not None:
0766 projection["server_host"] = server_host
0767 serialized = len(json.dumps(projection, separators=(",", ":"), default=str))
0768 if serialized > MAX_SERIALIZED_BYTES:
0769 raise ValueError(
0770 f"platform projection serializes to {serialized} bytes, over "
0771 f"the {MAX_SERIALIZED_BYTES} bound")
0772 return projection, observed_at
0773
0774
0775 def publish_platform_state() -> PlatformPublication:
0776 """Measure, assess, and atomically publish the platform component.
0777 Publication is unconditional: the gauges change every interval."""
0778 projection, observed_at = platform_projection()
0779 with transaction.atomic():
0780 registration_update = register_component(
0781 scope=SCOPE,
0782 name=COMPONENT_NAME,
0783 publisher_identity=PUBLISHER_IDENTITY,
0784 registration=PLATFORM_REGISTRATION,
0785 component_schema_version=1,
0786 )
0787 update = publish_component(
0788 scope=SCOPE,
0789 name=COMPONENT_NAME,
0790 publisher_identity=PUBLISHER_IDENTITY,
0791 data=projection,
0792 assessed_at=observed_at,
0793 source_as_of=observed_at,
0794 assessment_policy_version=ASSESSMENT_POLICY_VERSION,
0795 )
0796 return PlatformPublication(
0797 registration_update=registration_update,
0798 update=update,
0799 projection=projection,
0800 observed_at=observed_at,
0801 )
0802
0803
0804 def compact_platform_publication_report(publication: PlatformPublication) -> str:
0805 projection = publication.projection
0806 heartbeats = projection["heartbeats"]
0807 return json.dumps(
0808 {
0809 "scope": SCOPE,
0810 "component": COMPONENT_NAME,
0811 "revision": max(publication.update.revision,
0812 publication.registration_update.revision),
0813 "content_changed": publication.update.content_changed,
0814 "interval": projection["interval"],
0815 "db_connections": projection["database"].get("connections"),
0816 "running": heartbeats.get("running"),
0817 "heartbeats_received": heartbeats.get("received"),
0818 "heartbeat_yield": heartbeats.get("yield"),
0819 "server_latency_ms": projection["server"].get("latency_ms"),
0820 "server_ok": projection["server"].get("ok"),
0821 "pandamon_front_ms": projection["pandamon"]["front"].get("latency_ms"),
0822 "pandamon_workers_ms": projection["pandamon"]["workers"].get("latency_ms"),
0823 "reporter_status": projection["reporter_status"],
0824 "assessment": projection["assessment"]["overall"],
0825 "observed_at": publication.observed_at.isoformat(),
0826 },
0827 indent=2,
0828 sort_keys=True,
0829 )