"""Metrics layer — structured telemetry for the ingestion pipeline. Tracks per-run metrics, adapter health, verdict distribution, and score statistics. All stored in SQLite alongside entries for zero-cost persistence. Usage inside a pipeline run: from oracle.metrics import MetricsRun m = MetricsRun(db_path) m.log_adapter('arxiv', fetched=42, errors=0, runtime_ms=1200) m.log_verdicts(publish=2, watch=28, archive=8, drop=4) m.log_scores([5.2, 6.1, 3.0, ...]) m.log_summaries(useful=38, fallback=4) m.save() """ import json import sqlite3 import time from datetime import datetime, timezone from pathlib import Path from typing import Optional SCHEMA = """ CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, metric_name TEXT NOT NULL, value REAL, metadata TEXT, recorded_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS adapter_health ( id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, adapter_name TEXT NOT NULL, items_fetched INTEGER DEFAULT 0, errors INTEGER DEFAULT 0, runtime_ms INTEGER DEFAULT 0, consecutive_failures INTEGER DEFAULT 0, last_error TEXT, recorded_at TEXT NOT NULL ); -- Index for fast time-range queries CREATE INDEX IF NOT EXISTS idx_metrics_run_id ON metrics(run_id); CREATE INDEX IF NOT EXISTS idx_metrics_name ON metrics(metric_name); CREATE INDEX IF NOT EXISTS idx_adapter_health_name ON adapter_health(adapter_name); CREATE INDEX IF NOT EXISTS idx_adapter_health_run ON adapter_health(run_id); """ def migrate_metrics(conn: sqlite3.Connection) -> bool: """Idempotently create metrics tables. Returns True if tables were new.""" cur = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='metrics'" ) was_missing = cur.fetchone() is None conn.executescript(SCHEMA) conn.commit() return was_missing class MetricsRun: """Accumulate metrics for a single pipeline run, then persist atomically.""" def __init__(self, db_path: str | Path): self.db_path = str(db_path) self.conn = sqlite3.connect(self.db_path) self.conn.row_factory = sqlite3.Row migrate_metrics(self.conn) # Generate a run_id from run_log self.run_id = self._resolve_run_id() self.now = datetime.now(timezone.utc).isoformat() self._adapters: list[dict] = [] self._metrics: list[dict] = [] self._score_values: list[float] = [] self._verdict_counts: dict[str, int] = {} self._summary_counts: dict[str, int] = {"useful": 0, "fallback": 0} def _resolve_run_id(self) -> int: """Get or create the current run entry in run_log.""" cur = self.conn.execute( "SELECT MAX(id) as rid FROM run_log WHERE date(run_time) = date('now', 'utc')" ) rid = cur.fetchone()["rid"] if rid is None: self.conn.execute( "INSERT INTO run_log (run_time, total_fetched, total_stored, sources_ok, sources_failed, notes, failure_class) " "VALUES (?, 0, 0, '[]', '[]', 'metrics-session', '')", (self.now,), ) self.conn.commit() return self.conn.execute("SELECT last_insert_rowid()").fetchone()[0] return rid # ── Public API ── def log_adapter( self, name: str, fetched: int = 0, errors: int = 0, runtime_ms: int = 0, last_error: str | None = None, ) -> None: """Record per-adapter health metrics.""" # Calculate consecutive failures consecutive = self._consecutive_failures(name) if fetched > 0 or errors == 0: consecutive = 0 # Reset on success self._adapters.append({ "run_id": self.run_id, "adapter_name": name, "items_fetched": fetched, "errors": errors, "runtime_ms": runtime_ms, "consecutive_failures": consecutive, "last_error": last_error, "recorded_at": self.now, }) def log_verdicts( self, publish: int = 0, watch: int = 0, archive: int = 0, drop: int = 0, ) -> None: """Record verdict distribution for this run.""" self._verdict_counts = { "PUBLISH": publish, "WATCH": watch, "ARCHIVE": archive, "DROP": drop, } total = publish + watch + archive + drop self._metrics.append({ "run_id": self.run_id, "metric_name": "publish_rate", "value": publish / total if total else 0, "metadata": json.dumps({ "publish": publish, "watch": watch, "archive": archive, "drop": drop, "total": total, }), "recorded_at": self.now, }) def log_scores(self, scores: list[float]) -> None: """Record signal score distribution.""" if not scores: return self._score_values = scores sorted_scores = sorted(scores) n = len(sorted_scores) self._metrics.append({ "run_id": self.run_id, "metric_name": "score_mean", "value": sum(scores) / n, "metadata": json.dumps({ "count": n, "min": sorted_scores[0], "max": sorted_scores[-1], "p25": sorted_scores[n // 4], "p50": sorted_scores[n // 2], "p90": sorted_scores[int(n * 0.9)], "p99": sorted_scores[int(n * 0.99)] if n > 100 else sorted_scores[-1], }), "recorded_at": self.now, }) def log_summaries(self, useful: int = 0, fallback: int = 0) -> None: """Record summarization quality metrics.""" total = useful + fallback self._summary_counts = {"useful": useful, "fallback": fallback} if total: self._metrics.append({ "run_id": self.run_id, "metric_name": "summary_quality_rate", "value": useful / total, "metadata": json.dumps({ "useful": useful, "fallback": fallback, "total": total, }), "recorded_at": self.now, }) def log_metric( self, name: str, value: float, metadata: dict | None = None, ) -> None: """Record an arbitrary metric.""" self._metrics.append({ "run_id": self.run_id, "metric_name": name, "value": value, "metadata": json.dumps(metadata) if metadata else None, "recorded_at": self.now, }) def save(self) -> int: """Persist all accumulated metrics. Returns run_id.""" # Write adapter health for a in self._adapters: self.conn.execute( "INSERT INTO adapter_health " "(run_id, adapter_name, items_fetched, errors, runtime_ms, " " consecutive_failures, last_error, recorded_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( a["run_id"], a["adapter_name"], a["items_fetched"], a["errors"], a["runtime_ms"], a["consecutive_failures"], a["last_error"], a["recorded_at"], ), ) # Write metrics for m in self._metrics: self.conn.execute( "INSERT INTO metrics " "(run_id, metric_name, value, metadata, recorded_at) " "VALUES (?, ?, ?, ?, ?)", ( m["run_id"], m["metric_name"], m["value"], m["metadata"], m["recorded_at"], ), ) # Update run_log totals total_fetched = sum(a["items_fetched"] for a in self._adapters) total_errors = sum(a["errors"] for a in self._adapters) sources_ok = [a["adapter_name"] for a in self._adapters if a["items_fetched"] > 0] sources_failed = [a["adapter_name"] for a in self._adapters if a["errors"] > 0] self.conn.execute( "UPDATE run_log SET total_fetched = ?, total_stored = ?, " "sources_ok = ?, sources_failed = ?, notes = ? " "WHERE id = ?", ( total_fetched, total_fetched - total_errors, json.dumps(sources_ok), json.dumps(sources_failed), f"publish_rate={self._verdict_counts.get('PUBLISH', 0)}, " f"summary_quality={self._summary_counts.get('useful', 0)}/{sum(self._summary_counts.values()) or 1}", self.run_id, ), ) self.conn.commit() return self.run_id def close(self) -> None: self.conn.close() # ── Helpers ── def _consecutive_failures(self, adapter_name: str) -> int: """Count consecutive runs where adapter returned 0 items.""" cur = self.conn.execute( "SELECT items_fetched FROM adapter_health " "WHERE adapter_name = ? AND run_id < ? " "ORDER BY run_id DESC LIMIT 5", (adapter_name, self.run_id), ) count = 0 for row in cur: if row["items_fetched"] == 0: count += 1 else: break return count # ── Aggregation queries ── def get_publish_trend(db_path: str, days: int = 30) -> list[dict]: """Daily PUBLISH rate over the last N days.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.execute( "SELECT date(m.recorded_at) as day, " "ROUND(AVG(m.value), 4) as avg_publish_rate, " "COUNT(*) as runs " "FROM metrics m " "WHERE m.metric_name = 'publish_rate' " "AND m.recorded_at >= datetime('now', ?) " "GROUP BY day ORDER BY day DESC", (f"-{days} days",), ) results = [dict(r) for r in cur.fetchall()] conn.close() return results def get_adapter_health(db_path: str, days: int = 7) -> list[dict]: """Adapter health summary over last N days.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.execute( "SELECT adapter_name, " "COUNT(*) as runs, " "SUM(items_fetched) as total_fetched, " "SUM(errors) as total_errors, " "MAX(consecutive_failures) as max_consecutive_failures, " "AVG(runtime_ms) as avg_runtime_ms, " "MAX(last_error) as last_error " "FROM adapter_health " "WHERE recorded_at >= datetime('now', ?) " "GROUP BY adapter_name", (f"-{days} days",), ) results = [dict(r) for r in cur.fetchall()] conn.close() return results def get_score_distribution(db_path: str, days: int = 30) -> list[dict]: """Score distribution percentiles over last N days.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.execute( "SELECT date(recorded_at) as day, value as mean, metadata " "FROM metrics " "WHERE metric_name = 'score_mean' " "AND recorded_at >= datetime('now', ?) " "ORDER BY day DESC", (f"-{days} days",), ) results = [] for r in cur: meta = json.loads(r["metadata"]) if r["metadata"] else {} results.append({ "day": r["day"], "mean": r["mean"], "min": meta.get("min"), "max": meta.get("max"), "p25": meta.get("p25"), "p50": meta.get("p50"), "p90": meta.get("p90"), }) conn.close() return results def get_summary_quality(db_path: str, days: int = 30) -> list[dict]: """Summarization quality rate over time.""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.execute( "SELECT date(recorded_at) as day, ROUND(AVG(value), 4) as avg_quality " "FROM metrics " "WHERE metric_name = 'summary_quality_rate' " "AND recorded_at >= datetime('now', ?) " "GROUP BY day ORDER BY day DESC", (f"-{days} days",), ) results = [dict(r) for r in cur.fetchall()] conn.close() return results def get_metric_alerts(db_path: str) -> list[str]: """Generate alert strings based on metric anomalies.""" alerts = [] # Adapter failures health = get_adapter_health(db_path, days=7) for h in health: if h["max_consecutive_failures"] and h["max_consecutive_failures"] >= 3: alerts.append( f"⚠ {h['adapter_name']}: {h['max_consecutive_failures']} consecutive " f"failed runs. Last error: {h.get('last_error', 'unknown')}" ) if h["total_errors"] and h["runs"]: err_rate = h["total_errors"] / h["runs"] * 100 if err_rate > 50: alerts.append( f"⚠ {h['adapter_name']}: {err_rate:.0f}% error rate " f"({h['total_errors']}/{h['runs']} runs)" ) # Publish starvation trend = get_publish_trend(db_path, days=7) if trend: latest = trend[0]["avg_publish_rate"] if latest == 0: alerts.append("🔴 PUBLISH rate is 0% — scoring threshold may be too strict") elif latest < 0.01: alerts.append( f"🟡 PUBLISH rate is {latest:.1%} — " "consider lowering the signal threshold from 6.0" ) # Summary quality quality = get_summary_quality(db_path, days=7) if quality: latest = quality[0]["avg_quality"] if latest < 0.5: alerts.append( f"🟡 Summary quality at {latest:.0%} — " "most items falling back to title-only" ) return alerts