Files
web/hit-counter/main.py
T
carlitosbondandClaude Sonnet 5 44d6c1e5a9 Add hit counter service and expand frontend content sections
- New hit-counter FastAPI service (Prometheus metrics, SQLite storage), wired into Caddy (hits.carloselugo.com) and docker-compose
- Scrape hit-counter in Prometheus every 5m
- Add articles, about, contact, experience, and expertise sections to the frontend, with per-article hit tracking

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:31:52 +00:00

120 lines
3.9 KiB
Python

"""
hit-counter — minimal per-slug visit counter, with basic abuse protection
and a Prometheus /metrics endpoint for Grafana.
Throttling (in-memory, fine at this scale):
1. Per (ip, slug) cooldown — same visitor re-hitting the same article
within COOLDOWN_SECONDS doesn't increment the count again.
2. Per-ip global rate limit — caps total requests/minute from one IP.
State resets on container restart — acceptable for this use case.
"""
import time
from collections import defaultdict
from pathlib import Path
import sqlite3
from fastapi import FastAPI, Request, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import Gauge, generate_latest, CONTENT_TYPE_LATEST
DB_PATH = Path("/data/hits.db")
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
COOLDOWN_SECONDS = 30 * 60 # one counted hit per (ip, slug) per 30 min
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_REQUESTS = 20 # requests per ip per window, across all endpoints
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://carloselugo.com"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# (ip, slug) -> last counted timestamp
_last_hit: dict[tuple[str, str], float] = {}
# ip -> list of recent request timestamps
_request_log: dict[str, list[float]] = defaultdict(list)
# Prometheus gauge, one series per article slug.
# Gauge (not Counter) because we set it directly from the SQLite total
# on every /metrics scrape — Prometheus itself builds the time series
# from repeated scrapes, so we don't need to track history ourselves.
article_hits_gauge = Gauge("article_hits_total", "Total counted views per article", ["slug"])
def get_client_ip(request: Request) -> str:
# Caddy sets X-Forwarded-For when reverse-proxying.
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def enforce_rate_limit(ip: str):
now = time.time()
log = _request_log[ip]
while log and now - log[0] > RATE_LIMIT_WINDOW:
log.pop(0)
if len(log) >= RATE_LIMIT_MAX_REQUESTS:
raise HTTPException(status_code=429, detail="Too many requests")
log.append(now)
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS hits (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0)"
)
return conn
@app.post("/hit/{slug}")
def register_hit(slug: str, request: Request):
ip = get_client_ip(request)
enforce_rate_limit(ip)
now = time.time()
key = (ip, slug)
should_count = now - _last_hit.get(key, 0) > COOLDOWN_SECONDS
conn = get_db()
if should_count:
conn.execute(
"INSERT INTO hits (slug, count) VALUES (?, 1) "
"ON CONFLICT(slug) DO UPDATE SET count = count + 1",
(slug,),
)
conn.commit()
_last_hit[key] = now
total = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
conn.close()
count = total[0] if total else 0
article_hits_gauge.labels(slug=slug).set(count)
return {"slug": slug, "count": count, "counted": should_count}
@app.get("/hits/{slug}")
def get_hits(slug: str, request: Request):
enforce_rate_limit(get_client_ip(request))
conn = get_db()
row = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
conn.close()
return {"slug": slug, "count": row[0] if row else 0}
@app.get("/metrics")
def metrics():
# Refresh every gauge from the source of truth (SQLite) before
# Prometheus scrapes, so /metrics is correct even right after a restart.
conn = get_db()
rows = conn.execute("SELECT slug, count FROM hits").fetchall()
conn.close()
for slug, count in rows:
article_hits_gauge.labels(slug=slug).set(count)
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)