الملفات
Gamal0909 439a31ba93 Add CI/CD pipeline, architecture documentation, and monitoring application
- 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.
2026-07-27 23:08:39 +03:00

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)