"""Observability: online metrics/KPIs, an in-process structured log buffer, and offline metrics publishing. • Online tracking — every processed document updates the DB; KPIs are computed live (OCR completion, straight-through-processing rate, HITL rate, avg confidence, throughput, cost/doc, leading indicators). • Online logs — a ring buffer of structured events surfaced in the Admin tab. • Offline publishing— periodic KPI snapshots written to disk (and retained) so the IT team can ship them to a warehouse / BI tool out-of-band. """ from __future__ import annotations import json import logging import time from collections import deque from pathlib import Path # --- online structured log ring buffer --------------------------------------- _LOG = deque(maxlen=500) def log_event(level: str, message: str, **fields) -> None: _LOG.appendleft({"ts": time.time(), "level": level.upper(), "message": message, **fields}) def recent_logs(limit: int = 200, level: str | None = None) -> list[dict]: items = list(_LOG) if level: items = [e for e in items if e["level"] == level.upper()] return items[:limit] class _BufferHandler(logging.Handler): def emit(self, record): # pragma: no cover - thin try: log_event(record.levelname, record.getMessage(), logger=record.name) except Exception: pass def install_log_handler() -> None: root = logging.getLogger("aperture") if not any(isinstance(h, _BufferHandler) for h in root.handlers): root.addHandler(_BufferHandler()) root.setLevel(logging.INFO) # --- business KPIs (leading indicators) -------------------------------------- def business_kpis(db, metrics) -> dict: docs = db.list_documents(limit=100000) n = len(docs) if n == 0: return {"total_documents": 0, "note": "no documents processed yet"} posted = sum(1 for d in docs if d["posted"]) review = sum(1 for d in docs if d["requires_review"]) # "completion" = usable text obtained (digital text-layer OR OCR succeeded) ocr_ok = sum(1 for d in docs if "no_text_extracted" not in (d.get("flags") or [])) confs = [d["confidence"] for d in docs if d.get("confidence") is not None] low_conf = sum(1 for c in confs if c < 0.85) costs = [d.get("total_cost_usd", 0.0) for d in docs] times = [d.get("created_at", 0) for d in docs if d.get("created_at")] span_min = (max(times) - min(times)) / 60.0 if len(times) > 1 else 0.0 def by(field): out: dict[str, int] = {} for d in docs: out[d.get(field) or "unknown"] = out.get(d.get(field) or "unknown", 0) + 1 return out return { "total_documents": n, # leading / completion indicators "ocr_completion_rate": round(ocr_ok / n, 3), "straight_through_rate": round(posted / n, 3), # auto-posted, no human "hitl_rate": round(review / n, 3), # routed to review "avg_confidence": round(sum(confs) / len(confs), 3) if confs else None, "low_confidence_share": round(low_conf / n, 3), # leading indicator "throughput_docs_per_min": round(n / span_min, 2) if span_min > 0 else None, "cost_per_document_usd": round(sum(costs) / n, 6), "total_cost_usd": round(sum(costs), 6), # distributions "by_category": by("category"), "by_doc_type": by("doc_type"), "by_ocr_backend": by("ocr_backend"), # cross-link to inference metrics "cache_hit_rate": metrics.summary().get("cache_hit_rate"), "inference_savings_pct": metrics.summary().get("savings_pct"), } # --- offline publishing ------------------------------------------------------- def publish_offline_snapshot(db, metrics, out_dir: Path) -> dict: out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) snapshot = { "published_at": time.time(), "kpis": business_kpis(db, metrics), "inference": metrics.summary(), "category_counts": db.category_counts(), } ts = time.strftime("%Y%m%dT%H%M%S") path = out_dir / f"kpi_snapshot_{ts}.json" path.write_text(json.dumps(snapshot, indent=2)) # keep a stable 'latest' pointer (out_dir / "latest.json").write_text(json.dumps(snapshot, indent=2)) log_event("info", "offline KPI snapshot published", path=str(path)) return {"path": str(path), "snapshot": snapshot} def list_snapshots(out_dir: Path, limit: int = 20) -> list[str]: out_dir = Path(out_dir) if not out_dir.exists(): return [] files = sorted(out_dir.glob("kpi_snapshot_*.json"), reverse=True) return [f.name for f in files[:limit]]