Files

371 lines
14 KiB
Python
Raw Permalink Normal View History

"""Data observability (Disease #6 — Traceability / observability).
A lightweight background monitor that, per business table, tracks over time:
volume row count and its delta between cycles
freshness age of the newest record
schema the column set; a change raises a drift alert
It derives alerts (critical/warning/info) for volume drops, stalled ingestion,
stale data and schema drift, and keeps a rolling time-series per dataset for the
trend charts in the UI.
Endpoints:
GET /api/observability/metrics -> per-dataset series + current state
GET /api/observability/alerts -> active + recent alerts
POST /api/observability/run -> run one probe cycle now
POST /api/observability/config -> {enabled, interval_s, freshness_min}
"""
from __future__ import annotations
import threading
import time
import uuid
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, trino_scalar, discover_columns
router = APIRouter(prefix="/api/observability", tags=["observability"])
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": True,
"interval_s": 90.0,
"freshness_min": 30.0,
"running": False,
"cycles": 0,
"last_cycle_ts": 0.0,
"next_run_ts": 0.0,
"started_at": None,
"datasets": {
d["key"]: {"label": d["label"], "engine": d["engine"], "color": d["color"],
"table": d["fqtn"], "rows": None, "prev_rows": None, "delta": None,
"freshness_age_min": None, "columns": None, "schema_hash": None,
"series": deque(maxlen=80), "deltas": deque(maxlen=20),
"stalled_cycles": 0, "ts": None, "error": None}
for d in DATASETS
},
"alerts_active": {}, # dedup_key -> alert
"alerts_history": deque(maxlen=120),
"feed": deque(maxlen=40),
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _term(text: str, level: str = "info", phase: str = "observe") -> None:
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("infra-sentinel", text, level=level, phase=phase)
except Exception:
pass
def _raise_alert(dataset: str, atype: str, severity: str, message: str) -> None:
dedup = f"{dataset}:{atype}"
existing = _state["alerts_active"].get(dedup)
if existing:
existing["count"] += 1
existing["last_ts"] = _now().isoformat()
existing["message"] = message
return
alert = {"id": uuid.uuid4().hex[:8], "dataset": dataset, "type": atype,
"severity": severity, "message": message, "count": 1,
"ts": _now().isoformat(), "last_ts": _now().isoformat(), "resolved": False}
_state["alerts_active"][dedup] = alert
_state["alerts_history"].appendleft(dict(alert))
lvl = "err" if severity == "critical" else ("warn" if severity == "warning" else "info")
_term(f" ⚠ ALERT [{severity}] {dataset}: {message}", level=lvl, phase="alert")
_state["feed"].appendleft({"ts": _now().isoformat(), "text": f"[{severity}] {dataset}: {message}", "level": lvl})
def _clear_alert(dataset: str, atype: str) -> None:
dedup = f"{dataset}:{atype}"
a = _state["alerts_active"].pop(dedup, None)
if a:
a["resolved"] = True
a["resolved_ts"] = _now().isoformat()
_state["alerts_history"].appendleft({**a, "message": f"resolved: {a['message']}"})
def _native_freshness(ds: dict[str, Any]) -> float | None:
"""Epoch seconds of the newest record, read cheaply via the PK index
(ORDER BY <pk> DESC LIMIT 1) on the source DB. Returns None if unavailable."""
nc = ds.get("native_count")
ts = ds.get("ts_col")
key = ds.get("key_col")
if not nc or not ts:
return None
eng = nc[0]
try:
import sql_console as s
if eng == "postgres" and key:
import psycopg2
conn = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER,
password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=6)
try:
cur = conn.cursor()
cur.execute("SET statement_timeout = 6000")
cur.execute(f'SELECT extract(epoch FROM "{ts}") FROM public."{ds["table"]}" '
f'WHERE "{ts}" IS NOT NULL ORDER BY "{key}" DESC LIMIT 1')
row = cur.fetchone()
return float(row[0]) if row and row[0] is not None else None
finally:
conn.close()
if eng == "mysql" and key:
import pymysql
conn = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER,
password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=6)
try:
cur = conn.cursor()
cur.execute("SET SESSION MAX_EXECUTION_TIME = 6000")
cur.execute(f"SELECT UNIX_TIMESTAMP(`{ts}`) FROM `{ds['table']}` "
f"WHERE `{ts}` IS NOT NULL ORDER BY `{key}` DESC LIMIT 1")
row = cur.fetchone()
return float(row[0]) if row and row[0] is not None else None
finally:
conn.close()
if eng == "mongodb":
cli = s._mongo_client()
try:
doc = list(cli[ds["schema"]][ds["table"]].find({}, {ts: 1, "_id": 0}).sort("_id", -1).limit(1))
if doc:
import datetime as _dt
v = doc[0].get(ts)
if isinstance(v, _dt.datetime):
return v.timestamp()
finally:
cli.close()
except Exception:
return None
return None
def _probe(ds: dict[str, Any]) -> None:
key = ds["key"]
st = _state["datasets"][key]
fq = ds["fqtn"]
# volume — prefer a fast native planner estimate (postgres/mysql/mongo); use
# Trino metadata count for Iceberg; treat a slow/failed count as a soft miss.
rows = None
estimated = False
nc = ds.get("native_count")
if nc:
try:
import sql_console as _s
rows = _s._table_row_count(nc[0], nc[1])
estimated = True
except Exception:
rows = None
if rows is None:
try:
rows = int(trino_scalar(f"SELECT count(*) FROM {fq}", timeout=30.0) or 0)
except Exception as exc:
st["error"] = str(exc)[:140]
st["count_fails"] = st.get("count_fails", 0) + 1
return
st["error"] = None
st["count_fails"] = 0
st["estimated"] = estimated
prev = st["rows"]
st["prev_rows"] = prev
st["rows"] = rows
delta = (rows - prev) if prev is not None else None
st["delta"] = delta
st["series"].append({"t": _now().strftime("%H:%M:%S"), "rows": rows, "delta": delta or 0})
st["ts"] = _now().isoformat()
if delta is not None:
st["deltas"].append(delta)
# Guard against planner-estimate jitter: only alert on a material drop.
drop_threshold = max(2000, int(0.02 * (prev or 0)))
if delta < -drop_threshold:
_raise_alert(key, "volume_drop", "warning",
f"row count dropped by {abs(delta):,} ({prev:,}{rows:,})")
else:
_clear_alert(key, "volume_drop")
# stalled ingestion: no growth for several cycles on CDC sources. Skip for
# estimate-based counts (planner stats update lazily → false stalls).
if delta == 0 and not ds.get("curated") and not estimated:
st["stalled_cycles"] += 1
if st["stalled_cycles"] >= 4:
_raise_alert(key, "stalled", "warning",
f"no new rows for {st['stalled_cycles']} cycles")
else:
st["stalled_cycles"] = 0
_clear_alert(key, "stalled")
# spike (informational) — only for exact counts
pos = [d for d in st["deltas"] if d > 0]
if not estimated and pos and delta > (sum(pos) / len(pos)) * 6 and len(pos) >= 4:
_raise_alert(key, "spike", "info", f"volume spike +{delta:,} rows this cycle")
# freshness — cheap PK-indexed newest-row lookup for the CDC sources, with a
# short Trino max(ts) fallback for Iceberg tables.
if ds.get("ts_col"):
epoch = _native_freshness(ds)
if epoch is None:
try:
v = trino_scalar(f'SELECT to_unixtime(max("{ds["ts_col"]}")) FROM {fq}', timeout=10.0)
epoch = float(v) if v is not None else None
except Exception:
epoch = None
if epoch:
age = max(0.0, (time.time() - float(epoch)) / 60.0)
st["freshness_age_min"] = round(age, 1)
if age > float(_state["freshness_min"]) and not ds.get("curated"):
_raise_alert(key, "stale", "warning",
f"newest record is {age:.0f}m old (> {_state['freshness_min']:.0f}m)")
else:
_clear_alert(key, "stale")
# schema drift
cols = discover_columns(ds)
if cols:
names = sorted(c["name"] for c in cols)
h = hash(tuple(names))
if st["schema_hash"] is not None and h != st["schema_hash"]:
old = set(st["columns"] or [])
new = set(names)
added = sorted(new - old)
removed = sorted(old - new)
parts = []
if added:
parts.append(f"+{', '.join(added)}")
if removed:
parts.append(f"-{', '.join(removed)}")
_raise_alert(key, "schema_drift", "critical", "schema changed: " + " ".join(parts))
st["columns"] = names
st["schema_hash"] = h
def run_cycle() -> dict[str, Any]:
if _state["running"]:
return {"ok": True, "skipped": "already running"}
_state["running"] = True
try:
_term(f"═══ observability sweep {_state['cycles'] + 1} — volume · freshness · schema ═══",
level="info", phase="cycle")
for ds in DATASETS:
if not _state["enabled"]:
break
try:
_probe(ds)
except Exception as exc:
_state["datasets"][ds["key"]]["error"] = str(exc)[:140]
_state["cycles"] += 1
_state["last_cycle_ts"] = time.time()
_state["next_run_ts"] = time.time() + float(_state["interval_s"])
active = len(_state["alerts_active"])
_term(f"═══ sweep {_state['cycles']} done — {active} active alert(s) ═══",
level=("warn" if active else "ok"), phase="cycle")
finally:
_state["running"] = False
return {"ok": True, "active_alerts": len(_state["alerts_active"])}
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(28)
while True:
try:
if _state["enabled"]:
run_cycle()
except Exception:
pass
time.sleep(max(30.0, float(_state["interval_s"])))
threading.Thread(target=_loop, daemon=True, name="observability").start()
def metrics_view() -> dict[str, Any]:
with _lock:
datasets = []
for ds in DATASETS:
st = _state["datasets"][ds["key"]]
datasets.append({
"key": ds["key"], "label": st["label"], "engine": st["engine"],
"color": st["color"], "table": st["table"], "rows": st["rows"],
"delta": st["delta"], "freshness_age_min": st["freshness_age_min"],
"columns": len(st["columns"]) if st["columns"] else None,
"stalled_cycles": st["stalled_cycles"], "error": st["error"],
"ts": st["ts"], "series": list(st["series"]),
})
return {
"ok": True, "enabled": _state["enabled"], "interval_s": _state["interval_s"],
"freshness_min": _state["freshness_min"], "running": _state["running"],
"cycles": _state["cycles"], "last_cycle_ts": _state["last_cycle_ts"],
"next_run_ts": _state["next_run_ts"],
"alert_counts": _alert_counts(),
"datasets": datasets, "feed": list(_state["feed"])[:20],
}
def _alert_counts() -> dict[str, int]:
counts = {"critical": 0, "warning": 0, "info": 0}
for a in _state["alerts_active"].values():
counts[a["severity"]] = counts.get(a["severity"], 0) + 1
counts["total"] = len(_state["alerts_active"])
return counts
def summary_for_llm() -> dict[str, Any]:
with _lock:
return {
"active_alerts": _alert_counts(),
"alerts": [{"dataset": a["dataset"], "type": a["type"], "severity": a["severity"],
"message": a["message"]} for a in _state["alerts_active"].values()],
}
@router.get("/metrics")
async def get_metrics() -> JSONResponse:
return JSONResponse(metrics_view())
@router.get("/alerts")
async def get_alerts() -> JSONResponse:
with _lock:
return JSONResponse({
"ok": True,
"counts": _alert_counts(),
"active": sorted(_state["alerts_active"].values(),
key=lambda a: {"critical": 0, "warning": 1, "info": 2}.get(a["severity"], 3)),
"history": list(_state["alerts_history"])[:60],
})
@router.post("/run")
async def post_run() -> JSONResponse:
from starlette.concurrency import run_in_threadpool
res = await run_in_threadpool(run_cycle)
return JSONResponse({**res, "metrics": metrics_view()})
@router.post("/config")
async def post_config(body: dict = Body(default={})) -> JSONResponse:
if "enabled" in body:
_state["enabled"] = bool(body["enabled"])
if "interval_s" in body:
try:
_state["interval_s"] = max(30.0, min(900.0, float(body["interval_s"])))
except Exception:
pass
if "freshness_min" in body:
try:
_state["freshness_min"] = max(1.0, min(1440.0, float(body["freshness_min"])))
except Exception:
pass
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "freshness_min": _state["freshness_min"]})