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.
This commit is contained in:
mo
2026-06-29 17:43:37 +00:00
parent d066def8b4
commit f36c8906bc
15 changed files with 2479 additions and 4 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py hive_bench_seed.json .
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py movements.py dataflow.py streaming_ops.py spark_workbench.py hadoop_sql.py hdfs_kafka.py webhdfs_util.py pii_catalog.py platform_context.py trino_federated.py etl_offload.py agent_activity.py lake_meta.py lineage.py dq_monitor.py observability.py catalog_governance.py hive_bench_seed.json .
RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201
+314
View File
@@ -0,0 +1,314 @@
"""Data governance: ownership, stewardship, glossary & posture
(Diseases #3 Ownership and #5 Governance).
Ownership/steward assignments are kept in a local store (so the feature always
works for the demo regardless of OpenMetadata ingestion state) and are
best-effort mirrored to OpenMetadata. Users/teams for the assignment dropdowns
are read from OpenMetadata when reachable. The governance *posture* view
combines, per dataset: ownership, PII/masking (pii_catalog), live data-quality
score (dq_monitor), observability alerts and a simple data-contract check.
Endpoints:
GET /api/governance/datasets -> ownership matrix (+ orphan flag)
GET /api/governance/users -> assignable users/teams
POST /api/governance/assign -> set owner/steward/team/tier (native + OM)
GET /api/governance/glossary -> business glossary terms
GET /api/governance/posture -> combined governance posture per dataset
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from lake_meta import DATASETS, DATASET_BY_KEY
router = APIRouter(prefix="/api/governance", tags=["governance"])
OPENMETADATA_URL = os.getenv("OPENMETADATA_URL", "").rstrip("/")
OPENMETADATA_TOKEN = os.getenv("OPENMETADATA_TOKEN", "")
OWNERS_PATH = Path(os.getenv("GOVERNANCE_OWNERS_PATH", "/data/governance_owners.json"))
CONTRACTS_PATH = Path(os.getenv("GOVERNANCE_CONTRACTS_PATH", "/data/governance_contracts.json"))
# Default data contracts (quality SLAs) per dataset — used when none stored.
DEFAULT_CONTRACTS = {
"_default": {"min_score": 80, "min_completeness": 95, "min_freshness_min": 60,
"max_critical_alerts": 0},
}
# Seed business glossary so the term list is never empty even before OM ingestion.
SEED_GLOSSARY = [
{"name": "Customer", "description": "A person or organization that places sales orders.",
"related": ["customer_id", "customer_name", "customer_email"], "domain": "Sales"},
{"name": "Order", "description": "A sales transaction with an amount, channel and status.",
"related": ["order_id", "amount", "order_status"], "domain": "Sales"},
{"name": "Revenue", "description": "Sum of order amounts over a period.",
"related": ["amount", "currency"], "domain": "Sales"},
{"name": "Employee", "description": "A member of the workforce tracked via HR events.",
"related": ["employee_id", "department", "role_name"], "domain": "People"},
{"name": "PII", "description": "Personally Identifiable Information — masked per policy.",
"related": ["customer_email", "national_id", "billing_iban"], "domain": "Governance"},
{"name": "Telemetry", "description": "Device metric readings over time.",
"related": ["device_id", "metric_type", "metric_value"], "domain": "IoT"},
]
def _headers() -> dict[str, str]:
h = {"Accept": "application/json"}
if OPENMETADATA_TOKEN:
h["Authorization"] = f"Bearer {OPENMETADATA_TOKEN}"
return h
def _load(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text())
except Exception:
return {}
def _save(path: Path, data: dict[str, Any]) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2))
except Exception:
pass
# ── OpenMetadata best-effort ────────────────────────────────────────────────
def _om_users() -> list[dict[str, Any]]:
if not OPENMETADATA_URL:
return []
out: list[dict[str, Any]] = []
try:
with httpx.Client(timeout=8.0, verify=False) as c:
r = c.get(f"{OPENMETADATA_URL}/api/v1/users?limit=50&isBot=false", headers=_headers())
if r.status_code == 200:
for u in r.json().get("data", []):
out.append({"id": u.get("id"), "name": u.get("name"),
"display": u.get("displayName") or u.get("name"), "type": "user"})
rt = c.get(f"{OPENMETADATA_URL}/api/v1/teams?limit=50", headers=_headers())
if rt.status_code == 200:
for t in rt.json().get("data", []):
out.append({"id": t.get("id"), "name": t.get("name"),
"display": t.get("displayName") or t.get("name"), "type": "team"})
except Exception:
pass
return out
def _om_glossary() -> list[dict[str, Any]]:
if not OPENMETADATA_URL:
return []
try:
with httpx.Client(timeout=8.0, verify=False) as c:
r = c.get(f"{OPENMETADATA_URL}/api/v1/glossaryTerms?limit=100", headers=_headers())
if r.status_code == 200:
return [{"name": t.get("name"), "description": t.get("description", ""),
"domain": "OpenMetadata", "related": []}
for t in r.json().get("data", [])]
except Exception:
pass
return []
def _om_patch_owner(om_fqn: str, user: dict[str, Any]) -> bool:
"""Best-effort: set table owner in OpenMetadata via JSON-Patch."""
if not OPENMETADATA_URL or not om_fqn or not user.get("id"):
return False
try:
with httpx.Client(timeout=8.0, verify=False) as c:
g = c.get(f"{OPENMETADATA_URL}/api/v1/tables/name/{om_fqn}", headers=_headers())
if g.status_code != 200:
return False
patch = [{"op": "add", "path": "/owners/0",
"value": {"id": user["id"], "type": user.get("type", "user")}}]
h = {**_headers(), "Content-Type": "application/json-patch+json"}
p = c.patch(f"{OPENMETADATA_URL}/api/v1/tables/name/{om_fqn}",
headers=h, content=json.dumps(patch))
return p.status_code in (200, 201)
except Exception:
return False
# ── helpers pulling from sibling modules (all best-effort) ──────────────────
def _pii_summary(pii_key: str) -> dict[str, Any]:
try:
from pii_catalog import get_pii
for d in get_pii().get("datasets", []):
if d.get("key") == pii_key:
return {"pii_count": d.get("pii_count", 0),
"all_masked": d.get("all_masked", False),
"masked": sum(1 for c in d.get("pii_columns", []) if c.get("masked")),
"unmasked": sum(1 for c in d.get("pii_columns", []) if not c.get("masked"))}
except Exception:
pass
return {"pii_count": 0, "all_masked": False, "masked": 0, "unmasked": 0}
def _dq_card(key: str) -> dict[str, Any]:
try:
from dq_monitor import scorecards_view
for c in scorecards_view().get("cards", []):
if c.get("key") == key:
return {"score": c.get("score"), "issues": c.get("issues", []),
"freshness_age_min": c.get("freshness_age_min")}
except Exception:
pass
return {"score": None, "issues": []}
def _obs_alerts(key: str) -> list[dict[str, Any]]:
try:
from observability import _state, _lock
with _lock:
return [{"type": a["type"], "severity": a["severity"], "message": a["message"]}
for a in _state["alerts_active"].values() if a["dataset"] == key]
except Exception:
return []
# ── views ───────────────────────────────────────────────────────────────────
def datasets_view() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
rows = []
orphans = 0
stewarded = 0
for ds in DATASETS:
rec = owners.get(ds["key"], {})
owner = rec.get("owner")
steward = rec.get("steward")
if not owner:
orphans += 1
if steward:
stewarded += 1
rows.append({
"key": ds["key"], "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": ds["fqtn"], "domain": rec.get("domain") or ds.get("domain"),
"owner": owner, "steward": steward, "team": rec.get("team"),
"tier": rec.get("tier"), "classification": rec.get("classification"),
"updated_at": rec.get("updated_at"),
"orphan": not owner,
"pii": _pii_summary(ds.get("pii_key", ds["key"])),
})
return {"ok": True, "datasets": rows,
"summary": {"total": len(rows), "orphans": orphans, "stewarded": stewarded,
"owned": len(rows) - orphans},
"om_connected": bool(OPENMETADATA_URL)}
class AssignRequest(BaseModel):
key: str
owner: str | None = None
steward: str | None = None
team: str | None = None
tier: str | None = None
classification: str | None = None
def posture_view() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
contracts = _load(CONTRACTS_PATH)
default_c = DEFAULT_CONTRACTS["_default"]
out = []
compliant = 0
for ds in DATASETS:
key = ds["key"]
rec = owners.get(key, {})
contract = {**default_c, **(contracts.get(key, {}))}
pii = _pii_summary(ds.get("pii_key", key))
dq = _dq_card(key)
alerts = _obs_alerts(key)
crit = sum(1 for a in alerts if a["severity"] == "critical")
checks = []
score = dq.get("score")
checks.append({"name": "DQ score", "ok": score is not None and score >= contract["min_score"],
"value": score, "target": contract["min_score"]})
checks.append({"name": "Owner assigned", "ok": bool(rec.get("owner")),
"value": rec.get("owner") or "", "target": "assigned"})
checks.append({"name": "PII masked", "ok": pii["unmasked"] == 0,
"value": f'{pii["masked"]}/{pii["pii_count"]}', "target": "all"})
checks.append({"name": "Critical alerts", "ok": crit <= contract["max_critical_alerts"],
"value": crit, "target": contract["max_critical_alerts"]})
ok = all(c["ok"] for c in checks)
if ok:
compliant += 1
out.append({
"key": key, "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": ds["fqtn"], "owner": rec.get("owner"), "steward": rec.get("steward"),
"tier": rec.get("tier"), "pii": pii, "dq_score": score, "issues": dq.get("issues", []),
"alerts": alerts, "contract": contract, "checks": checks, "compliant": ok,
})
return {"ok": True, "datasets": out,
"summary": {"total": len(out), "compliant": compliant,
"non_compliant": len(out) - compliant}}
def summary_for_llm() -> dict[str, Any]:
owners = _load(OWNERS_PATH)
return {
"owners": {k: {"owner": v.get("owner"), "steward": v.get("steward"), "tier": v.get("tier")}
for k, v in owners.items()},
"orphan_datasets": [d["key"] for d in DATASETS if not owners.get(d["key"], {}).get("owner")],
}
@router.get("/datasets")
async def get_datasets() -> JSONResponse:
return JSONResponse(datasets_view())
@router.get("/users")
async def get_users() -> JSONResponse:
users = _om_users()
if not users:
users = [{"id": None, "name": n, "display": n, "type": "user"}
for n in ("admin", "bart", "mo")] + \
[{"id": None, "name": "Organization", "display": "Organization", "type": "team"}]
return JSONResponse({"ok": True, "users": users, "om_connected": bool(OPENMETADATA_URL)})
@router.post("/assign")
async def assign(body: AssignRequest) -> JSONResponse:
if body.key not in DATASET_BY_KEY:
return JSONResponse({"ok": False, "error": f"unknown dataset {body.key}"}, status_code=400)
owners = _load(OWNERS_PATH)
rec = dict(owners.get(body.key, {}))
for field in ("owner", "steward", "team", "tier", "classification"):
val = getattr(body, field)
if val is not None:
rec[field] = val or None
rec["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
owners[body.key] = rec
_save(OWNERS_PATH, owners)
om_synced = False
if body.owner:
ds = DATASET_BY_KEY[body.key]
user = next((u for u in _om_users() if u.get("display") == body.owner or u.get("name") == body.owner), None)
if user:
om_synced = _om_patch_owner(ds.get("om_fqn", ""), user)
return JSONResponse({"ok": True, "key": body.key, "record": rec, "om_synced": om_synced})
@router.get("/glossary")
async def get_glossary() -> JSONResponse:
terms = _om_glossary()
source = "openmetadata"
if not terms:
terms = SEED_GLOSSARY
source = "seed"
return JSONResponse({"ok": True, "source": source, "count": len(terms), "terms": terms})
@router.get("/posture")
async def get_posture() -> JSONResponse:
return JSONResponse(posture_view())
+345
View File
@@ -0,0 +1,345 @@
"""Continuous data-quality monitoring (Disease #2 — Dirty Data).
Where dq-api runs Great-Expectations/Soda on *uploaded* files, this agent runs
quality checks continuously against the *live* business tables through Trino and
produces a per-dataset scorecard (0-100) across five dimensions:
completeness non-null ratio across columns
uniqueness distinct/key ratio (duplicate detection)
validity domain rules (non-negative amounts, present timestamps)
freshness age of the newest record vs a threshold
volume row count + delta vs the previous cycle
A rolling score history powers trend sparklines. The loop streams the exact
SQL it runs into the Data Custodian terminal so operators can see the checks.
Endpoints:
GET /api/dq/scorecards -> all datasets, dimensions, score, trend
GET /api/dq/scorecard/{key} -> one dataset detail
POST /api/dq/run -> run one cycle now
POST /api/dq/config -> {enabled, interval_s, sample}
"""
from __future__ import annotations
import threading
import time
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, DATASET_BY_KEY, trino, trino_scalar, discover_columns
router = APIRouter(prefix="/api/dq", tags=["data-quality"])
FRESHNESS_THRESHOLD_MIN = 60.0 # newest row older than this => freshness degraded
MAX_COLS = 24
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": True,
"interval_s": 180.0,
"sample": 20000,
"running": False,
"cycles": 0,
"last_cycle_ts": 0.0,
"next_run_ts": 0.0,
"started_at": None,
"cards": {}, # key -> latest scorecard
"history": {d["key"]: deque(maxlen=60) for d in DATASETS},
"cols_cache": {}, # key -> [{name,type}]
"feed": deque(maxlen=40),
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _term(text: str, level: str = "info", phase: str = "dq") -> None:
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("data-custodian", text, level=level, phase=phase)
except Exception:
pass
def _feed(text: str, level: str = "info") -> None:
_state["feed"].appendleft({"ts": _now().isoformat(), "text": text, "level": level})
def _score_color(score: float) -> str:
if score >= 90:
return "#34d399"
if score >= 75:
return "#fbbf24"
if score >= 50:
return "#fb923c"
return "#f87171"
def _obs_volume_freshness(key: str) -> tuple[int | None, float | None]:
"""Read row count + freshness from the observability monitor (in-memory)."""
try:
from observability import _state, _lock
with _lock:
st = _state["datasets"].get(key) or {}
return st.get("rows"), st.get("freshness_age_min")
except Exception:
return None, None
def _columns(ds: dict[str, Any]) -> list[dict[str, str]]:
key = ds["key"]
cols = _state["cols_cache"].get(key)
if not cols:
cols = discover_columns(ds)
if cols:
_state["cols_cache"][key] = cols
return cols or []
def _assess(ds: dict[str, Any], sample: int) -> dict[str, Any]:
key = ds["key"]
fq = ds["fqtn"]
cols = _columns(ds)
col_names = [c["name"] for c in cols][:MAX_COLS]
dims: dict[str, Any] = {}
issues: list[str] = []
col_profiles: list[dict[str, Any]] = []
# ── one bounded query: completeness + uniqueness + validity over a sample ──
has_unique_key = ds.get("unique_key", True)
key_col = ds.get("key_col") if ds.get("key_col") in col_names else (col_names[0] if col_names else None)
if not has_unique_key:
key_col = None # no single-column unique key (e.g. composite PK) → skip dedup
ts_col = ds.get("ts_col") if ds.get("ts_col") in col_names else None
amt_col = ds.get("amount_col") if ds.get("amount_col") in col_names else None
selects = ["count(*) AS n"]
for i, c in enumerate(col_names):
selects.append(f'count("{c}") AS c{i}')
if key_col:
selects.append(f'count(DISTINCT "{key_col}") AS dk')
if amt_col:
selects.append(f'count_if("{amt_col}" >= 0) AS amt_ok')
if ts_col:
selects.append(f'count_if("{ts_col}" IS NOT NULL) AS ts_ok')
sql = f"SELECT {', '.join(selects)} FROM (SELECT * FROM {fq} LIMIT {sample}) s"
_term(f"$ trino: profile {key} ({len(col_names)} cols · sample {sample:,})", level="cmd", phase="dq")
cols_out, rows = trino(sql, timeout=40.0)
rec = dict(zip(cols_out, rows[0])) if rows else {}
n = int(rec.get("n") or 0)
if n > 0:
# completeness
non_null_ratios = []
for i, c in enumerate(col_names):
cnt = int(rec.get(f"c{i}") or 0)
ratio = cnt / n
non_null_ratios.append(ratio)
col_profiles.append({"name": c, "completeness": round(ratio * 100, 1),
"nulls": n - cnt})
completeness = round(100.0 * sum(non_null_ratios) / max(1, len(non_null_ratios)), 1)
dims["completeness"] = completeness
worst = sorted(col_profiles, key=lambda x: x["completeness"])[:3]
for w in worst:
if w["completeness"] < 95:
issues.append(f'{w["name"]} {100 - w["completeness"]:.0f}% null')
# uniqueness / dedup
if key_col and rec.get("dk") is not None:
dk = int(rec["dk"])
uniq = round(100.0 * dk / n, 1)
dims["uniqueness"] = uniq
dups = n - dk
if dups > 0:
issues.append(f"{dups:,} duplicate {key_col} in sample")
# validity
valid_parts = []
if amt_col and rec.get("amt_ok") is not None:
valid_parts.append(int(rec["amt_ok"]) / n)
if ts_col and rec.get("ts_ok") is not None:
valid_parts.append(int(rec["ts_ok"]) / n)
if valid_parts:
validity = round(100.0 * sum(valid_parts) / len(valid_parts), 1)
dims["validity"] = validity
if validity < 99 and amt_col:
issues.append(f"negative/invalid {amt_col}")
# ── volume + freshness: reuse the observability monitor's in-memory probes
# (it already polls count(*) and max(ts)). We deliberately do NOT issue our
# own count/max here — those are expensive full scans on 25-54M-row tables —
# so a DQ cycle stays fast and the score is driven by the cheap sample. ──
volume, fresh_age_min = _obs_volume_freshness(key)
if volume is None:
volume = n # sample size until observability reports the real count
if fresh_age_min is not None:
fresh_score = 100.0 if fresh_age_min <= FRESHNESS_THRESHOLD_MIN else max(
0.0, 100.0 - (fresh_age_min - FRESHNESS_THRESHOLD_MIN) / 5.0)
dims["freshness"] = round(fresh_score, 1)
if fresh_age_min > FRESHNESS_THRESHOLD_MIN and not ds.get("curated"):
issues.append(f"stale {fresh_age_min:.0f}m")
score = round(sum(dims.values()) / len(dims), 1) if dims else 0.0
prev = _state["cards"].get(key, {})
prev_vol = prev.get("volume")
delta = (volume - prev_vol) if (volume is not None and prev_vol is not None) else None
card = {
"key": key, "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": fq, "domain": ds.get("domain"),
"score": score, "score_color": _score_color(score),
"dimensions": dims, "volume": volume, "volume_delta": delta,
"freshness_age_min": round(fresh_age_min, 1) if fresh_age_min is not None else None,
"issues": issues[:5], "columns": len(col_names),
"worst_columns": sorted(col_profiles, key=lambda x: x["completeness"])[:5],
"ts": _now().isoformat(),
}
lvl = "ok" if score >= 90 else ("warn" if score >= 60 else "err")
_term(f"{key}: score {score} · " + " · ".join(f"{k} {v}" for k, v in dims.items())
+ (f" · {len(issues)} issue(s)" if issues else ""), level=lvl, phase="dq")
return card
def run_cycle() -> dict[str, Any]:
if _state["running"]:
return {"ok": True, "skipped": "already running"}
_state["running"] = True
sample = int(_state["sample"])
n_ok = 0
try:
_term(f"═══ DQ monitor cycle {_state['cycles'] + 1} — live tables via Trino ═══",
level="info", phase="cycle")
for ds in DATASETS:
if not _state["enabled"]:
break
try:
card = _assess(ds, sample)
except Exception as exc:
card = {"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "error": str(exc)[:160],
"issues": [f"check failed: {str(exc)[:80]}"], "ts": _now().isoformat()}
_term(f"{ds['key']}: {str(exc)[:120]}", level="err", phase="dq")
with _lock:
_state["cards"][ds["key"]] = card
if card.get("score") is not None:
_state["history"][ds["key"]].append({"t": _now().strftime("%H:%M"), "score": card["score"]})
n_ok += 1
_state["cycles"] += 1
_state["last_cycle_ts"] = time.time()
_state["next_run_ts"] = time.time() + float(_state["interval_s"])
avg = _overall_score()
_feed(f"cycle {_state['cycles']}{n_ok}/{len(DATASETS)} datasets scored · platform DQ {avg}")
_term(f"═══ cycle {_state['cycles']} done — platform DQ score {avg} ═══", level="ok", phase="cycle")
finally:
_state["running"] = False
return {"ok": True, "scored": n_ok, "platform_score": _overall_score()}
def _overall_score() -> float | None:
vals = [c["score"] for c in _state["cards"].values() if c.get("score") is not None]
return round(sum(vals) / len(vals), 1) if vals else None
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(20)
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="dq-monitor").start()
def scorecards_view() -> dict[str, Any]:
with _lock:
cards = []
for ds in DATASETS:
c = _state["cards"].get(ds["key"])
if c:
cards.append({**c, "trend": list(_state["history"][ds["key"]])})
else:
cards.append({"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "issues": [],
"trend": [], "pending": True})
# platform dimension averages
dim_avg: dict[str, list[float]] = {}
for c in cards:
for k, v in (c.get("dimensions") or {}).items():
dim_avg.setdefault(k, []).append(v)
return {
"ok": True,
"enabled": _state["enabled"],
"interval_s": _state["interval_s"],
"sample": _state["sample"],
"running": _state["running"],
"cycles": _state["cycles"],
"last_cycle_ts": _state["last_cycle_ts"],
"next_run_ts": _state["next_run_ts"],
"platform_score": _overall_score(),
"dimension_averages": {k: round(sum(v) / len(v), 1) for k, v in dim_avg.items()},
"cards": cards,
"feed": list(_state["feed"])[:20],
}
def summary_for_llm() -> dict[str, Any]:
with _lock:
return {
"platform_dq_score": _overall_score(),
"datasets": [{"key": c["key"], "score": c.get("score"),
"issues": c.get("issues", []), "freshness_age_min": c.get("freshness_age_min")}
for c in _state["cards"].values()],
}
@router.get("/scorecards")
async def get_scorecards() -> JSONResponse:
return JSONResponse(scorecards_view())
@router.get("/scorecard/{key}")
async def get_scorecard(key: str) -> JSONResponse:
with _lock:
c = _state["cards"].get(key)
if not c:
return JSONResponse({"ok": False, "error": "unknown or not yet scored"}, status_code=404)
return JSONResponse({"ok": True, "card": {**c, "trend": list(_state["history"].get(key, []))}})
@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, "scorecards": scorecards_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(1800.0, float(body["interval_s"])))
except Exception:
pass
if "sample" in body:
try:
_state["sample"] = max(1000, min(200000, int(body["sample"])))
except Exception:
pass
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "sample": _state["sample"]})
+36
View File
@@ -671,6 +671,21 @@ def collect_governance(log: TerminalLogFn | None = None) -> dict[str, Any]:
"mysql_hr.hr.employee_events -> iceberg.curated_masked.employee_events_masked (PII masked)",
"hdfs:/data/historical/sales_orders -> iceberg.hadoop.historical_sales_hdfs",
]
try:
from dq_monitor import summary_for_llm as _dq
out["data_quality"] = _dq()
except Exception as exc:
out["data_quality"] = {"error": str(exc)}
try:
from observability import summary_for_llm as _obs
out["observability"] = _obs()
except Exception as exc:
out["observability"] = {"error": str(exc)}
try:
from catalog_governance import summary_for_llm as _own
out["ownership"] = _own()
except Exception as exc:
out["ownership"] = {"error": str(exc)}
return out
@@ -700,6 +715,27 @@ def _section_governance(g: dict[str, Any]) -> list[str]:
lines.append(" Lineage:")
for ln in g.get("lineage") or []:
lines.append(f" - {ln}")
dq = g.get("data_quality") or {}
if isinstance(dq, dict) and "error" not in dq:
lines.append(f" Data quality (continuous, live tables): platform score {dq.get('platform_dq_score')}")
for d in dq.get("datasets") or []:
iss = f" issues: {', '.join(d['issues'][:3])}" if d.get("issues") else ""
lines.append(f" - {d['key']}: score {d.get('score')}{iss}")
own = g.get("ownership") or {}
if isinstance(own, dict) and "error" not in own:
orph = own.get("orphan_datasets") or []
lines.append(f" Ownership: {len(own.get('owners', {}))} assigned"
+ (f", orphans (no owner): {', '.join(orph)}" if orph else ", no orphans"))
for k, v in (own.get("owners") or {}).items():
if v.get("owner"):
lines.append(f" - {k}: owner={v.get('owner')} steward={v.get('steward') or ''} tier={v.get('tier') or ''}")
obs = g.get("observability") or {}
if isinstance(obs, dict) and "error" not in obs:
ac = obs.get("active_alerts") or {}
lines.append(f" Observability alerts: {ac.get('total', 0)} active "
f"(critical={ac.get('critical', 0)}, warning={ac.get('warning', 0)})")
for a in (obs.get("alerts") or [])[:5]:
lines.append(f" - [{a['severity']}] {a['dataset']}: {a['message']}")
return lines
+147
View File
@@ -0,0 +1,147 @@
"""Shared lakehouse metadata + Trino helpers.
Single source of truth for the business datasets the governance / data-quality /
lineage / observability features operate on, plus a small synchronous Trino
client. Imported by dq_monitor.py, observability.py, lineage.py and
catalog_governance.py so every feature reasons about the exact same tables that
the rest of the Command Center (pii_catalog, etl_offload, trino_federated)
already exposes.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo")
# Canonical business datasets, aligned with pii_catalog.DATASETS / etl_offload.
# fqtn = fully-qualified Trino table name (catalog.schema.table)
# om_fqn = OpenMetadata FQN best-effort (governance falls back to native store)
DATASETS: list[dict[str, Any]] = [
{"key": "orders", "label": "Sales orders", "engine": "PostgreSQL", "color": "#fbbf24",
"catalog": "postgres_sales", "schema": "public", "table": "sales_orders",
"fqtn": "postgres_sales.public.sales_orders", "pii_key": "postgres",
"om_fqn": "atc_trino.postgres_sales.public.sales_orders",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"native_count": ("postgres", "public.sales_orders"),
"domain": "Sales", "topic": "atc.public.sales_orders"},
{"key": "hr", "label": "HR events", "engine": "MySQL", "color": "#60a5fa",
"catalog": "mysql_hr", "schema": "hr", "table": "employee_events",
"fqtn": "mysql_hr.hr.employee_events", "pii_key": "mysql",
"om_fqn": "atc_trino.mysql_hr.hr.employee_events",
"key_col": "event_id", "ts_col": "event_ts", "amount_col": "salary_change",
"native_count": ("mysql", "hr.employee_events"),
"domain": "People", "topic": "atc.hr.employee_events"},
{"key": "supply", "label": "Supply chain events", "engine": "MongoDB", "color": "#a78bfa",
"catalog": "mongodb_supplychain", "schema": "supplychain", "table": "events",
"fqtn": "mongodb_supplychain.supplychain.events", "pii_key": "mongodb",
"om_fqn": "atc_trino.mongodb_supplychain.supplychain.events",
"key_col": "event_id", "ts_col": "ts", "amount_col": "amount",
"native_count": ("mongodb", "supplychain.events"),
"domain": "Supply Chain", "topic": "atc.supplychain.events"},
{"key": "telemetry", "label": "Device telemetry", "engine": "Cassandra", "color": "#22d3ee",
"catalog": "cassandra_telemetry", "schema": "telemetry", "table": "device_metrics",
"fqtn": "cassandra_telemetry.telemetry.device_metrics", "pii_key": "cassandra",
"om_fqn": "atc_trino.cassandra_telemetry.telemetry.device_metrics",
"key_col": "device_id", "ts_col": "metric_ts", "amount_col": "metric_value",
"unique_key": False,
"domain": "IoT", "topic": "atc.telemetry.device_metrics"},
{"key": "curated", "label": "Curated masked (Iceberg)", "engine": "Iceberg / Trino", "color": "#34d399",
"catalog": "iceberg", "schema": "curated_masked", "table": "sales_orders_masked",
"fqtn": "iceberg.curated_masked.sales_orders_masked", "pii_key": "curated",
"om_fqn": "atc_trino.iceberg.curated_masked.sales_orders_masked",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
{"key": "hadoop", "label": "Historical sales (HDFS)", "engine": "Iceberg / HDFS", "color": "#f472b6",
"catalog": "iceberg", "schema": "hadoop", "table": "historical_sales_hdfs",
"fqtn": "iceberg.hadoop.historical_sales_hdfs", "pii_key": "hadoop",
"om_fqn": "atc_trino.iceberg.hadoop.historical_sales_hdfs",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
]
DATASET_BY_KEY = {d["key"]: d for d in DATASETS}
# Heuristics for picking the freshness / key column when discovering schema.
_TS_HINTS = ("_ts", "ts", "updated_at", "created_at", "event_time", "modified", "time")
_KEY_HINTS = ("_id", "id", "uuid", "key", "pk")
def trino(sql: str, timeout: float = 25.0) -> tuple[list[str], list[list[Any]]]:
"""Run a Trino statement, following nextUri pages. Returns (columns, rows).
`timeout` is both the per-request timeout AND an overall wall-clock deadline,
so a long full-scan (e.g. count(*) on a huge Cassandra table) is aborted and
cancelled instead of looping over nextUri pages for minutes and blocking the
caller's thread.
"""
import time as _t
cols: list[str] = []
rows: list[list[Any]] = []
deadline = _t.monotonic() + timeout
with httpx.Client(timeout=min(timeout, 15.0)) as client:
d = client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(),
headers={"X-Trino-User": TRINO_USER}).json()
while True:
if d.get("error"):
raise RuntimeError(d["error"].get("message", "trino error"))
c = d.get("columns")
if c and not cols:
cols = [x["name"] for x in c]
rows += d.get("data") or []
nxt = d.get("nextUri")
if not nxt:
break
if _t.monotonic() > deadline:
try:
client.delete(nxt) # cancel the running query server-side
except Exception:
pass
raise TimeoutError(f"trino query exceeded {timeout}s deadline")
d = client.get(nxt).json()
return cols, rows
def trino_scalar(sql: str, timeout: float = 25.0) -> Any:
_c, rows = trino(sql, timeout=timeout)
if rows and rows[0]:
return rows[0][0]
return None
def discover_columns(ds: dict[str, Any], timeout: float = 12.0) -> list[dict[str, str]]:
"""[{name, type}] for a dataset's table via information_schema."""
sql = (f"SELECT column_name, data_type FROM {ds['catalog']}.information_schema.columns "
f"WHERE table_name = '{ds['table']}'")
if ds.get("schema"):
sql += f" AND table_schema = '{ds['schema']}'"
try:
_c, rows = trino(sql, timeout=timeout)
return [{"name": r[0], "type": r[1]} for r in rows]
except Exception:
return []
def pick_ts_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
for c in columns:
if "timestamp" in (c.get("type") or "").lower() or "date" in (c.get("type") or "").lower():
return c["name"]
for n in names:
if any(h in n.lower() for h in _TS_HINTS):
return n
return default if default in names else None
def pick_key_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
if default in names:
return default
for n in names:
if any(n.lower() == h or n.lower().endswith(h) for h in _KEY_HINTS):
return n
return names[0] if names else None
+248
View File
@@ -0,0 +1,248 @@
"""Data lineage graph for the Command Center (Disease #6 — Traceability).
Models the real end-to-end pipeline as an explicit, staged node/edge graph:
source DB → Debezium/Kafka (CDC) → Spark / Kafka Connect → S3 Parquet lake
→ Iceberg lakehouse (curated_masked / hadoop) → Trino federation
→ serving (Elasticsearch, RAG/ChromaDB, vLLM, Command Center chat)
Table-backed nodes are enriched with live Trino row counts. For the sales path
we expose column-level lineage with PII / masking status (reusing pii_catalog),
so an operator can trace any field from source to the masked curated layer.
OpenMetadata table lineage is layered in best-effort where the FQN resolves.
Endpoints:
GET /api/lineage/graph?dataset=<key> -> nodes + edges (+ column links)
GET /api/lineage/datasets -> selectable datasets
"""
from __future__ import annotations
import os
import time
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, DATASET_BY_KEY, trino_scalar
router = APIRouter(prefix="/api/lineage", tags=["lineage"])
OPENMETADATA_URL = os.getenv("OPENMETADATA_URL", "").rstrip("/")
OPENMETADATA_TOKEN = os.getenv("OPENMETADATA_TOKEN", "")
_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_TTL = 30.0
# Which source datasets flow through the CDC → lakehouse pipeline.
_SOURCE_KEYS = ["orders", "hr", "supply", "telemetry"]
def _is_active() -> tuple[bool, bool]:
gen = arch = False
try:
from trino_federated import generator_active
gen = bool(generator_active())
except Exception:
pass
try:
from storage_s3 import archive_active
arch = bool(archive_active())
except Exception:
pass
return gen, arch
def _obs_counts() -> dict[str, int | None]:
"""Reuse the row counts the observability monitor already polls in-memory,
so the lineage graph never issues its own (potentially slow) count(*)."""
try:
from observability import _state, _lock
with _lock:
return {k: v.get("rows") for k, v in _state["datasets"].items()}
except Exception:
return {}
def _row_count(ds_key: str, counts: dict[str, int | None]) -> int | None:
return counts.get(ds_key)
def _pii_columns(ds_key: str) -> list[dict[str, Any]]:
"""Column-level detail with PII/masking flags from pii_catalog (best-effort).
ds_key is a lake_meta key; map it to the pii_catalog key first."""
pii_key = (DATASET_BY_KEY.get(ds_key, {}) or {}).get("pii_key", ds_key)
try:
from pii_catalog import get_pii
data = get_pii()
for d in data.get("datasets", []):
if d.get("key") == pii_key or d.get("node_id") == pii_key:
return [{"name": c["name"], "category": c.get("category"),
"masked": bool(c.get("masked")), "pii": True}
for c in d.get("pii_columns", [])]
except Exception:
pass
return []
def _om_lineage(fqn: str) -> dict[str, int]:
"""OpenMetadata table lineage (best-effort, short timeout). Kept out of the
hot path by default; only called when LINEAGE_OM_ENRICH=1 is set."""
if not OPENMETADATA_URL or not fqn or os.getenv("LINEAGE_OM_ENRICH", "0") != "1":
return {}
url = f"{OPENMETADATA_URL}/api/v1/lineage/table/name/{fqn}?upstreamDepth=1&downstreamDepth=1"
headers = {"Accept": "application/json"}
if OPENMETADATA_TOKEN:
headers["Authorization"] = f"Bearer {OPENMETADATA_TOKEN}"
try:
with httpx.Client(timeout=4.0, verify=False) as c:
r = c.get(url, headers=headers)
if r.status_code != 200:
return {}
j = r.json()
return {"upstream": len(j.get("upstreamEdges") or []),
"downstream": len(j.get("downstreamEdges") or [])}
except Exception:
return {}
def _build() -> dict[str, Any]:
gen_active, arch_active = _is_active()
counts = _obs_counts()
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
seen: set[str] = set()
def node(nid: str, label: str, ntype: str, stage: int, **meta: Any) -> None:
if nid in seen:
return
seen.add(nid)
nodes.append({"id": nid, "label": label, "type": ntype, "stage": stage, "meta": meta})
def edge(src: str, dst: str, label: str, kind: str, active: bool = False) -> None:
edges.append({"id": f"{src}->{dst}", "source": src, "target": dst,
"label": label, "kind": kind, "active": active})
# Stage 5/6 shared serving nodes
node("trino", "Trino", "engine", 5, engine="Trino", note="Federated SQL across every catalog")
node("es", "Elasticsearch", "serving", 6, engine="Elasticsearch", note="Business + infra search indices")
node("rag", "ChromaDB (RAG)", "serving", 6, engine="ChromaDB", note="Vector store / embeddings")
node("vllm", "vLLM", "serving", 6, engine="vLLM", note="LLM inference (GPU)")
node("cc", "Command Center", "serving", 6, engine="React", note="Knowledge Chat & dashboards")
edge("rag", "vllm", "context", "serve", active=True)
edge("vllm", "cc", "answers", "serve", active=True)
edge("trino", "es", "index business data", "serve")
edge("es", "cc", "search", "serve")
edge("trino", "cc", "dashboards", "serve")
for key in _SOURCE_KEYS:
ds = DATASET_BY_KEY[key]
src_id = f"src_{key}"
topic_id = f"topic_{key}"
lake_id = f"lake_{key}"
rows = _row_count(key, counts)
cols = _pii_columns(key)
node(src_id, f"{ds['engine']}\n{ds['table']}", "source", 0,
engine=ds["engine"], table=ds["fqtn"], rows=rows, domain=ds.get("domain"),
columns=cols, ts_col=ds.get("ts_col"), key_col=ds.get("key_col"),
om=_om_lineage(ds.get("om_fqn", "")))
# CDC path (Debezium → Kafka topic)
if ds.get("topic"):
node(topic_id, f"Kafka\n{ds['topic']}", "stream", 1,
engine="Kafka", topic=ds["topic"], note="Debezium CDC topic")
edge(src_id, topic_id, "Debezium CDC", "cdc", active=gen_active)
node("connect", "Kafka Connect", "stream", 2, engine="Kafka Connect",
note="Debezium source + HDFS/S3 sinks")
edge(topic_id, "connect", "consume", "cdc", active=gen_active)
# ETL offload (direct source → S3 Parquet lake)
node(lake_id, f"S3 lake\nlake/{key}", "storage", 3, engine="ObjectScale S3",
path=f"s3://data/lake/{key}/", note="Parquet parts (ETL offload)")
edge(src_id, lake_id, "ETL offload (Parquet)", "batch", active=arch_active)
# Spark / Connect → curated + hadoop Iceberg tables
node("spark", "Spark", "compute", 2, engine="Spark", note="Transform & mask → curated")
edge("connect", "spark", "stream", "transform", active=gen_active)
cur = DATASET_BY_KEY["curated"]
had = DATASET_BY_KEY["hadoop"]
node("iceberg_curated", f"Iceberg\n{cur['table']}", "lakehouse", 4, engine="Iceberg",
table=cur["fqtn"], rows=_row_count("curated", counts), masked_layer=True,
note="PII-masked curated layer", columns=_pii_columns("curated"),
om=_om_lineage(cur.get("om_fqn", "")))
node("iceberg_hadoop", f"Iceberg/HDFS\n{had['table']}", "lakehouse", 4, engine="Iceberg / HDFS",
table=had["fqtn"], rows=_row_count("hadoop", counts), note="Historical sales on HDFS",
om=_om_lineage(had.get("om_fqn", "")))
edge("spark", "iceberg_curated", "mask + write", "transform", active=arch_active)
edge("lake_orders", "iceberg_hadoop", "register external", "batch", active=arch_active)
edge("src_orders", "iceberg_curated", "curate (masked)", "transform")
# Lakehouse → Trino
for nid in ("iceberg_curated", "iceberg_hadoop"):
edge(nid, "trino", "query", "serve")
for key in _SOURCE_KEYS:
edge(f"src_{key}", "trino", "federate", "serve")
# Lake → RAG (documents/parquet feeding embeddings is conceptual)
edge("iceberg_curated", "rag", "embed (masked-safe)", "serve")
# Column-level links: source sales_orders → curated masked
column_links: list[dict[str, Any]] = []
src_cols = {c["name"] for c in _pii_columns("orders")}
cur_cols = {c["name"] for c in _pii_columns("curated")}
for cn in sorted(src_cols & cur_cols):
masked = any(c["name"] == cn and c["masked"] for c in _pii_columns("curated"))
column_links.append({"source": "src_orders", "target": "iceberg_curated",
"column": cn, "masked": masked})
return {
"ok": True,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"active": {"generator": gen_active, "archive": arch_active},
"stages": ["Sources", "CDC / Stream", "Compute", "Lake storage",
"Lakehouse", "Federation", "Serving"],
"nodes": nodes,
"edges": edges,
"column_links": column_links,
"om_connected": bool(OPENMETADATA_URL),
}
def get_graph(use_cache: bool = True) -> dict[str, Any]:
now = time.time()
if use_cache and _cache["data"] and now - _cache["ts"] < _TTL:
return _cache["data"]
data = _build()
_cache["data"] = data
_cache["ts"] = now
return data
@router.get("/datasets")
async def datasets() -> JSONResponse:
return JSONResponse({"ok": True, "datasets": [
{"key": d["key"], "label": d["label"], "engine": d["engine"],
"color": d["color"], "table": d["fqtn"]}
for d in DATASETS]})
@router.get("/graph")
async def graph(dataset: str | None = None, refresh: bool = False) -> JSONResponse:
from starlette.concurrency import run_in_threadpool
data = await run_in_threadpool(get_graph, not refresh)
if dataset and dataset in DATASET_BY_KEY:
# Filter to nodes reachable from / to the selected source, keep serving spine.
keep = {f"src_{dataset}", f"topic_{dataset}", f"lake_{dataset}",
"connect", "spark", "iceberg_curated", "iceberg_hadoop",
"trino", "es", "rag", "vllm", "cc"}
nodes = [n for n in data["nodes"] if n["id"] in keep]
node_ids = {n["id"] for n in nodes}
edges = [e for e in data["edges"] if e["source"] in node_ids and e["target"] in node_ids]
out = {**data, "nodes": nodes, "edges": edges, "focus": dataset}
return JSONResponse(out)
return JSONResponse(data)
+8
View File
@@ -57,6 +57,10 @@ from spark_workbench import router as spark_workbench_router
from pii_catalog import router as pii_router
from trino_federated import router as federated_router
from etl_offload import router as etl_offload_router
from lineage import router as lineage_router
from dq_monitor import router as dq_router
from observability import router as observability_router
from catalog_governance import router as governance_router
from ssh_terminal import ssh_session
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
from node_ops import build_node_detail, probe_node, run_node_probe_task
@@ -768,6 +772,10 @@ app.include_router(spark_workbench_router)
app.include_router(pii_router)
app.include_router(federated_router)
app.include_router(etl_offload_router)
app.include_router(lineage_router)
app.include_router(dq_router)
app.include_router(observability_router)
app.include_router(governance_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
+370
View File
@@ -0,0 +1,370 @@
"""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"]})