#!/usr/bin/env python3 """Metrics collector for https://mithal.space. Every 60 seconds (or once with --once) it measures availability, page latency, DNS resolution time, TLS certificate lifetime and search-endpoint latency, then appends one record to the JSON array the dashboard reads. Record schema: {"ts": "", "up": bool, "code": int|null, "latency_ms": float|null, "dns_ms": float|null, "ssl_days_left": int|null, "search_ms": float|null} A failing measurement never aborts the run — it is recorded as null. Usage: python collect.py # loop every 60s python collect.py --once # single collection, then exit """ import argparse import json import os import socket import ssl import sys import time from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlparse import requests # --- Configuration ----------------------------------------------------------- TARGET_URL = os.getenv("TARGET_URL", "https://mithal.space") # Confirmed working search endpoint for mithal.space. SEARCH_URL = os.getenv("SEARCH_URL", "https://mithal.space/search?q=test") INTERVAL_S = float(os.getenv("INTERVAL_S", "60")) TIMEOUT_S = float(os.getenv("TIMEOUT_S", "10")) RETENTION_HOURS = float(os.getenv("RETENTION_HOURS", "48")) # The dashboard is served as the web root and fetches "data/metrics.json", # so the data directory lives inside dashboard/ both locally and in the image. DEFAULT_METRICS_FILE = ( Path(__file__).resolve().parent.parent / "dashboard" / "data" / "metrics.json" ) METRICS_FILE = Path(os.getenv("METRICS_FILE", str(DEFAULT_METRICS_FILE))) USER_AGENT = "mithal-monitor/1.0 (+uptime collector)" def now_utc() -> datetime: return datetime.now(timezone.utc) def host_port(url: str) -> tuple: """Extract (hostname, port) from a URL, defaulting to 443 for https.""" parsed = urlparse(url) port = parsed.port or (443 if parsed.scheme == "https" else 80) return parsed.hostname, port # --- Individual measurements (each returns None on failure) ------------------ def measure_page(url: str): """Timed GET of the home page. Returns (code|None, latency_ms|None).""" started = time.perf_counter() try: resp = requests.get( url, timeout=TIMEOUT_S, headers={"User-Agent": USER_AGENT}, allow_redirects=True ) latency_ms = round((time.perf_counter() - started) * 1000, 2) return resp.status_code, latency_ms except requests.RequestException: # Connection refused, DNS failure, TLS error, timeout — no status code. return None, None def measure_search(url: str): """Timed GET of the search endpoint. Returns search_ms|None.""" started = time.perf_counter() try: resp = requests.get( url, timeout=TIMEOUT_S, headers={"User-Agent": USER_AGENT}, allow_redirects=True ) if resp.status_code >= 400: return None return round((time.perf_counter() - started) * 1000, 2) except requests.RequestException: return None def measure_dns(hostname: str, port: int): """Timed DNS resolution. Returns dns_ms|None.""" started = time.perf_counter() try: socket.getaddrinfo(hostname, port) return round((time.perf_counter() - started) * 1000, 2) except (socket.gaierror, OSError): return None def measure_ssl_days_left(hostname: str, port: int): """Days until the TLS certificate expires. Returns int|None.""" context = ssl.create_default_context() try: with socket.create_connection((hostname, port), timeout=TIMEOUT_S) as sock: with context.wrap_socket(sock, server_hostname=hostname) as tls: cert = tls.getpeercert() except (ssl.SSLError, socket.error, OSError): return None not_after = (cert or {}).get("notAfter") if not not_after: return None try: # OpenSSL format, always in GMT: "Sep 12 23:59:59 2026 GMT" expires = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace( tzinfo=timezone.utc ) except ValueError: return None return (expires - now_utc()).days def collect_once() -> dict: """Run every measurement and build one record. Never raises.""" hostname, port = host_port(TARGET_URL) dns_ms = measure_dns(hostname, port) if hostname else None code, latency_ms = measure_page(TARGET_URL) ssl_days_left = measure_ssl_days_left(hostname, port) if hostname else None search_ms = measure_search(SEARCH_URL) return { "ts": now_utc().isoformat(), "up": code is not None and 200 <= code < 400, "code": code, "latency_ms": latency_ms, "dns_ms": dns_ms, "ssl_days_left": ssl_days_left, "search_ms": search_ms, } # --- Storage ----------------------------------------------------------------- def load_records(path: Path) -> list: """Read the existing JSON array; tolerate a missing or corrupt file.""" if not path.exists(): return [] try: with path.open("r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, list) else [] except (json.JSONDecodeError, OSError): print(f"[warn] {path} unreadable or corrupt — starting a fresh array", file=sys.stderr) return [] def prune(records: list) -> list: """Drop records older than the retention window.""" cutoff = now_utc() - timedelta(hours=RETENTION_HOURS) kept = [] for rec in records: if not isinstance(rec, dict): continue try: ts = datetime.fromisoformat(str(rec.get("ts"))) except (TypeError, ValueError): continue if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) if ts >= cutoff: kept.append(rec) return kept def append_record(path: Path, record: dict) -> None: """Append a record, prune the window, and write atomically.""" path.parent.mkdir(parents=True, exist_ok=True) records = prune(load_records(path)) records.append(record) tmp = path.with_suffix(path.suffix + ".tmp") with tmp.open("w", encoding="utf-8") as fh: json.dump(records, fh, indent=2) fh.write("\n") tmp.replace(path) def format_line(rec: dict) -> str: def num(value, suffix=""): return f"{value}{suffix}" if value is not None else "-" return ( f"{rec['ts']} {'UP ' if rec['up'] else 'DOWN'} " f"code={num(rec['code']):<4} " f"latency={num(rec['latency_ms'], 'ms'):<10} " f"dns={num(rec['dns_ms'], 'ms'):<9} " f"search={num(rec['search_ms'], 'ms'):<10} " f"ssl={num(rec['ssl_days_left'], 'd')}" ) def main() -> int: parser = argparse.ArgumentParser(description="Collect availability metrics for mithal.space.") parser.add_argument("--once", action="store_true", help="collect a single sample and exit") args = parser.parse_args() print(f"Target: {TARGET_URL}", flush=True) print(f"Search: {SEARCH_URL}", flush=True) print(f"Output: {METRICS_FILE} (keeping {RETENTION_HOURS:g}h)", flush=True) if not args.once: print(f"Interval: {INTERVAL_S:g}s", flush=True) while True: try: record = collect_once() append_record(METRICS_FILE, record) print(format_line(record), flush=True) except Exception as exc: # a bug here must not kill a long-running collector print(f"[error] collection failed: {exc!r}", file=sys.stderr, flush=True) if args.once: return 0 time.sleep(INTERVAL_S) if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\nStopped.", flush=True)