- Created a GitHub Actions workflow for CI/CD to deploy to Ghyamah, including testing, building, and pushing Docker images. - Added architecture design document for handling 15,000 requests per second, detailing system components, capacity planning, and cold start strategies. - Introduced a Python-based uptime/latency/SSL monitor with a static dashboard, utilizing standard libraries only. - Included Dockerfile and entrypoint script for the monitoring application, ensuring it runs as a non-root user and handles process management. - Added a .dockerignore file to exclude unnecessary files from the Docker build context. - Created an HTML dashboard for visualizing monitoring metrics, including uptime, latency, and SSL certificate status.
208 أسطر
7.6 KiB
Python
208 أسطر
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
monitor.py — Lightweight uptime / latency / SSL monitor.
|
|
|
|
Standard-library only (urllib, ssl, socket, json, time). Runs an infinite
|
|
loop, checks a target URL (and optionally a search endpoint) every
|
|
CHECK_INTERVAL seconds, and persists a rolling RETENTION_HOURS window of
|
|
results to a JSON file that the static dashboard reads.
|
|
|
|
Configuration is via environment variables so the same image can monitor
|
|
any site without a rebuild:
|
|
|
|
TARGET_URL Full URL to monitor (default: https://mithal.space)
|
|
SEARCH_PATH Path appended to origin for a (default: /search?q=test)
|
|
secondary "search" check. Set to "" to disable.
|
|
CHECK_INTERVAL Seconds between checks (default: 60)
|
|
RETENTION_HOURS How much history to keep (default: 24)
|
|
REQUEST_TIMEOUT Per-request timeout, seconds (default: 10)
|
|
DATA_FILE Where to write the JSON log (default: /app/web/data/metrics.json)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import urlparse
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Configuration
|
|
# --------------------------------------------------------------------------
|
|
|
|
TARGET_URL = os.environ.get("TARGET_URL", "https://mithal.space")
|
|
SEARCH_PATH = os.environ.get("SEARCH_PATH", "/search?q=test")
|
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "60"))
|
|
RETENTION_HOURS = float(os.environ.get("RETENTION_HOURS", "24"))
|
|
REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "10"))
|
|
DATA_FILE = os.environ.get("DATA_FILE", "/app/web/data/metrics.json")
|
|
USER_AGENT = "uptime-monitor/1.0 (+standard-library)"
|
|
|
|
_parsed = urlparse(TARGET_URL)
|
|
HOSTNAME = _parsed.hostname
|
|
PORT = _parsed.port or (443 if _parsed.scheme == "https" else 80)
|
|
SEARCH_URL = f"{_parsed.scheme}://{_parsed.netloc}{SEARCH_PATH}" if SEARCH_PATH else None
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def measure_dns(hostname: str):
|
|
"""Return DNS resolution time in milliseconds, or None on failure."""
|
|
start = time.perf_counter()
|
|
try:
|
|
socket.getaddrinfo(hostname, None)
|
|
except socket.gaierror as exc:
|
|
return None, str(exc)
|
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
return round(elapsed_ms, 2), None
|
|
|
|
|
|
def timed_get(url: str, timeout: float):
|
|
"""
|
|
Perform an HTTP GET and return (status_code, latency_ms, error_str).
|
|
latency_ms measures time-to-first-byte-of-full-response (connect + TLS
|
|
+ request + response), matching what a real visitor experiences.
|
|
"""
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
start = time.perf_counter()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
resp.read(1) # confirm the body actually starts streaming
|
|
status = resp.status
|
|
latency_ms = (time.perf_counter() - start) * 1000
|
|
return status, round(latency_ms, 2), None
|
|
except urllib.error.HTTPError as exc:
|
|
# Still a "successful" connection from a monitoring standpoint —
|
|
# the server responded, just with an error status.
|
|
latency_ms = (time.perf_counter() - start) * 1000
|
|
return exc.code, round(latency_ms, 2), None
|
|
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
|
|
latency_ms = (time.perf_counter() - start) * 1000
|
|
return None, round(latency_ms, 2), str(getattr(exc, "reason", exc))
|
|
|
|
|
|
def measure_ssl_expiry(hostname: str, port: int, timeout: float):
|
|
"""Return (days_remaining, error_str) for the TLS certificate."""
|
|
if not hostname:
|
|
return None, "no hostname"
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
with socket.create_connection((hostname, port), timeout=timeout) as sock:
|
|
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
|
|
cert = ssock.getpeercert()
|
|
not_after = cert.get("notAfter")
|
|
if not not_after:
|
|
return None, "no notAfter field in certificate"
|
|
expiry_dt = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(
|
|
tzinfo=timezone.utc
|
|
)
|
|
days_remaining = (expiry_dt - datetime.now(timezone.utc)).total_seconds() / 86400
|
|
return round(days_remaining, 1), None
|
|
except Exception as exc: # noqa: BLE001 — monitoring must never crash the loop
|
|
return None, str(exc)
|
|
|
|
|
|
def run_check() -> dict:
|
|
dns_ms, dns_err = measure_dns(HOSTNAME)
|
|
status, latency_ms, http_err = timed_get(TARGET_URL, REQUEST_TIMEOUT)
|
|
|
|
search_status, search_latency_ms, search_err = None, None, None
|
|
if SEARCH_URL:
|
|
search_status, search_latency_ms, search_err = timed_get(SEARCH_URL, REQUEST_TIMEOUT)
|
|
|
|
ssl_days, ssl_err = (None, None)
|
|
if _parsed.scheme == "https":
|
|
ssl_days, ssl_err = measure_ssl_expiry(HOSTNAME, PORT, REQUEST_TIMEOUT)
|
|
|
|
success = status is not None and 200 <= status < 400
|
|
|
|
return {
|
|
"timestamp": now_iso(),
|
|
"target": TARGET_URL,
|
|
"http_status": status,
|
|
"success": success,
|
|
"latency_ms": latency_ms,
|
|
"dns_ms": dns_ms,
|
|
"search_status": search_status,
|
|
"search_latency_ms": search_latency_ms,
|
|
"ssl_days_remaining": ssl_days,
|
|
"error": http_err or dns_err or ssl_err or search_err,
|
|
}
|
|
|
|
|
|
def load_history(path: str) -> list:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return data.get("checks", []) if isinstance(data, dict) else data
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return []
|
|
|
|
|
|
def prune(history: list, retention_hours: float) -> list:
|
|
cutoff = time.time() - retention_hours * 3600
|
|
pruned = []
|
|
for entry in history:
|
|
try:
|
|
ts = datetime.fromisoformat(entry["timestamp"]).timestamp()
|
|
except (KeyError, ValueError):
|
|
continue
|
|
if ts >= cutoff:
|
|
pruned.append(entry)
|
|
return pruned
|
|
|
|
|
|
def save_history(path: str, history: list) -> None:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
payload = {
|
|
"target": TARGET_URL,
|
|
"search_url": SEARCH_URL,
|
|
"updated_at": now_iso(),
|
|
"check_interval_seconds": CHECK_INTERVAL,
|
|
"retention_hours": RETENTION_HOURS,
|
|
"checks": history,
|
|
}
|
|
tmp_path = f"{path}.tmp"
|
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2)
|
|
os.replace(tmp_path, path) # atomic write so the dashboard never reads a half-written file
|
|
|
|
|
|
def main() -> None:
|
|
print(f"[monitor] target={TARGET_URL} interval={CHECK_INTERVAL}s "
|
|
f"retention={RETENTION_HOURS}h data_file={DATA_FILE}", flush=True)
|
|
|
|
history = load_history(DATA_FILE)
|
|
|
|
while True:
|
|
cycle_start = time.time()
|
|
result = run_check()
|
|
history.append(result)
|
|
history = prune(history, RETENTION_HOURS)
|
|
save_history(DATA_FILE, history)
|
|
|
|
status_str = result["http_status"] if result["http_status"] is not None else "ERR"
|
|
print(
|
|
f"[monitor] {result['timestamp']} status={status_str} "
|
|
f"latency={result['latency_ms']}ms dns={result['dns_ms']}ms "
|
|
f"ssl_days={result['ssl_days_remaining']} "
|
|
f"search_latency={result['search_latency_ms']}ms "
|
|
f"{'error=' + result['error'] if result['error'] else 'ok'}",
|
|
flush=True,
|
|
)
|
|
|
|
elapsed = time.time() - cycle_start
|
|
time.sleep(max(0, CHECK_INTERVAL - elapsed))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
sys.exit(0) |