from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta from statistics import median from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import ProbeResult, Tracker @dataclass(slots=True) class TrackerMetrics: uptime_24h: float | None uptime_7d: float | None uptime_30d: float | None latency_median_7d: float | None latency_p95_7d: float | None valid_rate_7d: float | None measurement_count: int measurement_count_7d: int tracking_days: float score: int | None provisional: bool def _percentile(sorted_values: list[float], p: float) -> float | None: if not sorted_values: return None if len(sorted_values) == 1: return sorted_values[0] k = (len(sorted_values) - 1) * p f = int(k) c = min(f + 1, len(sorted_values) - 1) if f == c: return sorted_values[f] return sorted_values[f] + (sorted_values[c] - sorted_values[f]) * (k - f) def uptime_ratio(results: Sequence[ProbeResult]) -> float | None: if not results: return None ups = sum(1 for r in results if r.status == "up" and r.response_valid) return ups / len(results) def latency_score(median_ms: float | None) -> int: if median_ms is None: return 0 if median_ms <= 150: return 20 if median_ms <= 300: return 16 if median_ms <= 600: return 10 if median_ms <= 1000: return 5 return 0 def confidence_score(count: int) -> int: if count < 3: return 0 if count < 10: return 1 if count < 20: return 3 return 5 def compute_score( *, uptime_7d: float | None, latency_median_7d: float | None, valid_rate_7d: float | None, measurement_count: int, ) -> int | None: if measurement_count < 3: return None avail = int(round((uptime_7d or 0.0) * 60)) lat = latency_score(latency_median_7d) valid = int(round((valid_rate_7d or 0.0) * 15)) conf = confidence_score(measurement_count) return max(0, min(100, avail + lat + valid + conf)) def metrics_from_results( tracker: Tracker, results: Sequence[ProbeResult], *, now: datetime | None = None ) -> TrackerMetrics: now = now or datetime.now(UTC) # Normalize naive datetimes from SQLite def _aware(dt: datetime) -> datetime: return dt if dt.tzinfo else dt.replace(tzinfo=UTC) first_seen = _aware(tracker.first_seen_at) cut_24h = now - timedelta(hours=24) cut_7d = now - timedelta(days=7) cut_30d = now - timedelta(days=30) aware_results = [] for r in results: # mutate view via checked_at comparison with aware times checked = _aware(r.checked_at) aware_results.append((checked, r)) r24 = [r for checked, r in aware_results if checked >= cut_24h] r7 = [r for checked, r in aware_results if checked >= cut_7d] r30 = [r for checked, r in aware_results if checked >= cut_30d] latencies = sorted(r.latency_ms for r in r7 if r.latency_ms is not None and r.response_valid) valid_rate = None if r7: valid_rate = sum(1 for r in r7 if r.response_valid) / len(r7) count = len(results) provisional = count < 10 score = compute_score( uptime_7d=uptime_ratio(r7), latency_median_7d=float(median(latencies)) if latencies else None, valid_rate_7d=valid_rate, measurement_count=count, ) tracking_days = (now - first_seen).total_seconds() / 86400.0 return TrackerMetrics( uptime_24h=uptime_ratio(r24), uptime_7d=uptime_ratio(r7), uptime_30d=uptime_ratio(r30), latency_median_7d=float(median(latencies)) if latencies else None, latency_p95_7d=_percentile(list(latencies), 0.95), valid_rate_7d=valid_rate, measurement_count=count, measurement_count_7d=len(r7), tracking_days=tracking_days, score=score, provisional=provisional, ) async def load_metrics(session: AsyncSession, tracker: Tracker) -> TrackerMetrics: result = await session.execute( select(ProbeResult) .where(ProbeResult.tracker_id == tracker.id) .order_by(ProbeResult.checked_at.desc()) .limit(5000) ) rows = list(result.scalars().all()) return metrics_from_results(tracker, rows)