Files
atc-agents/api/observability.py
mo f36c8906bc feat(governance): close the 6 data-disease gaps — DQ monitoring, ownership, access posture, lineage & observability
Adds native Command Center features (no new containers) integrated as sub-tabs
in the existing Data Explorer and Data Quality views:

- Continuous Data Quality (dq_monitor.py): live completeness/uniqueness/validity/
  freshness scorecards via Trino with rolling trends → DataQuality "Live Monitoring".
- Ownership & stewardship (catalog_governance.py): owner/steward/tier matrix,
  orphan detection, business glossary; local store best-effort synced to
  OpenMetadata (owner PATCH) → Data Explorer "Ownership".
- Access & policy posture: per-dataset compliance combining PII masking, ownership,
  live DQ and observability alerts vs data contracts → Data Explorer "Access & Policies".
- Lineage (lineage.py): staged source→CDC→Spark→S3→Iceberg→Trino→serving graph with
  live row counts and column-level PII/masking tracing → Data Explorer "Lineage".
- Observability (observability.py): volume/freshness/schema-drift monitoring with
  alerts → Data Explorer "Observability".
- Shared lake_meta.py dataset registry + bounded Trino client; fast native row-count
  and PK-indexed freshness so monitors stay cheap on 25-54M-row tables.
- LLM context (lab_context.py) enriched with DQ scores, ownership and active alerts.
2026-06-29 17:43:37 +00:00

371 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]})