ERP-DocIQ / backend /app /observability.py
kenmandal's picture
Deploy ERP-DocIQ: agentic OCR + IDP (MiniCPM-V 8B, Tesseract)
32b00ed verified
Raw
History Blame Contribute Delete
4.79 kB
"""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]]