Add self-hosted usage analytics: pageview tracking + admin stats dashboard
Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via a POST /api/track beacon into a new PageView table, and exposes aggregated daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown tables) built on recharts, with a switchable per-day pageviews chart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+101
-7
@@ -1,6 +1,9 @@
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from json import JSONEncoder
|
||||
|
||||
import socketio
|
||||
@@ -8,6 +11,7 @@ import socketio
|
||||
from bridzik import Bridzik, BridzikException, Card
|
||||
from db.db import init_db
|
||||
from api import auth as auth_module, history
|
||||
from api import stats as stats_module
|
||||
from api.auth import AuthError
|
||||
|
||||
|
||||
@@ -33,8 +37,95 @@ sio = socketio.AsyncServer(
|
||||
)
|
||||
|
||||
|
||||
async def _read_body(receive) -> bytes:
|
||||
body = b""
|
||||
while True:
|
||||
message = await receive()
|
||||
body += message.get("body", b"")
|
||||
if not message.get("more_body"):
|
||||
break
|
||||
return body
|
||||
|
||||
|
||||
async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"):
|
||||
await send({"type": "http.response.start", "status": status,
|
||||
"headers": [(b"content-type", content_type)]})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
|
||||
def _admin_authorized(scope) -> bool:
|
||||
token = os.environ.get("ADMIN_TOKEN", "")
|
||||
if not token:
|
||||
return False
|
||||
headers = dict(scope.get("headers") or [])
|
||||
auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore")
|
||||
return hmac.compare_digest(auth_header, f"Bearer {token}")
|
||||
|
||||
|
||||
def _client_ip(scope) -> str:
|
||||
# Behind nginx (docker-compose.prod.yaml) this is the real client IP --
|
||||
# the backend port is never published, so X-Forwarded-For can't be spoofed
|
||||
# by an external caller going around the proxy.
|
||||
headers = dict(scope.get("headers") or [])
|
||||
xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
client = scope.get("client")
|
||||
return client[0] if client else "unknown"
|
||||
|
||||
|
||||
# Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky
|
||||
# in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis).
|
||||
_ADMIN_ATTEMPT_LIMIT = 5
|
||||
_ADMIN_ATTEMPT_WINDOW = 300 # sekund
|
||||
_admin_failed_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def _admin_locked_out(ip: str) -> bool:
|
||||
cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW
|
||||
attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff]
|
||||
if attempts:
|
||||
_admin_failed_attempts[ip] = attempts
|
||||
else:
|
||||
_admin_failed_attempts.pop(ip, None)
|
||||
return len(attempts) >= _ADMIN_ATTEMPT_LIMIT
|
||||
|
||||
|
||||
def _register_admin_failure(ip: str) -> None:
|
||||
_admin_failed_attempts[ip].append(time.monotonic())
|
||||
|
||||
|
||||
async def _handle_track(scope, receive, send):
|
||||
try:
|
||||
data = json.loads((await _read_body(receive)) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
headers = dict(scope.get("headers") or [])
|
||||
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
|
||||
await stats_module.record_pageview(
|
||||
path=str(data.get("path", ""))[:200],
|
||||
referrer=str(data.get("referrer", ""))[:300],
|
||||
user_agent=user_agent,
|
||||
ip=_client_ip(scope),
|
||||
)
|
||||
await send({"type": "http.response.start", "status": 204, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
|
||||
async def _handle_admin_stats(scope, send):
|
||||
ip = _client_ip(scope)
|
||||
if _admin_locked_out(ip):
|
||||
return await _send_text(send, 429, b"too many attempts")
|
||||
if not _admin_authorized(scope):
|
||||
_register_admin_failure(ip)
|
||||
return await _send_text(send, 403, b"forbidden")
|
||||
data = await stats_module.get_daily_stats()
|
||||
await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
|
||||
|
||||
|
||||
async def _health_app(scope, receive, send):
|
||||
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks)."""
|
||||
"""Minimal ASGI handler for non-socket.io HTTP routes (liveness checks,
|
||||
pageview tracking beacon, admin stats)."""
|
||||
if scope["type"] == "lifespan":
|
||||
while True:
|
||||
message = await receive()
|
||||
@@ -46,12 +137,15 @@ async def _health_app(scope, receive, send):
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
return
|
||||
if scope["type"] == "http":
|
||||
ok = scope.get("path", "") in ("/health", "/healthz")
|
||||
status = 200 if ok else 404
|
||||
body = b"ok" if ok else b"not found"
|
||||
await send({"type": "http.response.start", "status": status,
|
||||
"headers": [(b"content-type", b"text/plain")]})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
path = scope.get("path", "")
|
||||
method = scope.get("method", "GET")
|
||||
if path in ("/health", "/healthz"):
|
||||
return await _send_text(send, 200, b"ok")
|
||||
if path == "/api/track" and method == "POST":
|
||||
return await _handle_track(scope, receive, send)
|
||||
if path == "/api/admin/stats" and method == "GET":
|
||||
return await _handle_admin_stats(scope, send)
|
||||
await _send_text(send, 404, b"not found")
|
||||
|
||||
|
||||
# Run with: uvicorn api:app --host 0.0.0.0 --port 5000
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
"""Self-hosted usage analytics: zapis pageview beacon + citanie agregatov pre /admin/stats.
|
||||
|
||||
Oddelene od api/history.py (ktory drzi zivu hru + restore-on-startup logiku),
|
||||
rovnako ako je api/auth.py samostatny modul.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import geoip2.database
|
||||
import geoip2.errors
|
||||
from sqlalchemy import extract, func, select
|
||||
from user_agents import parse as parse_ua
|
||||
|
||||
from db.db import async_session
|
||||
from db.models import Game, Guess, PageView, Player
|
||||
|
||||
_geoip_reader: "geoip2.database.Reader | None" = None
|
||||
_geoip_load_attempted = False
|
||||
|
||||
|
||||
def _country_for_ip(ip: str) -> str:
|
||||
"""ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je
|
||||
dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.)."""
|
||||
global _geoip_reader, _geoip_load_attempted
|
||||
if not ip:
|
||||
return ""
|
||||
if _geoip_reader is None:
|
||||
if _geoip_load_attempted:
|
||||
return ""
|
||||
_geoip_load_attempted = True
|
||||
path = os.environ.get("GEOIP_DB_PATH", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return ""
|
||||
_geoip_reader = geoip2.database.Reader(path)
|
||||
try:
|
||||
return _geoip_reader.country(ip).country.iso_code or ""
|
||||
except (geoip2.errors.AddressNotFoundError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "") -> None:
|
||||
ua = parse_ua(user_agent)
|
||||
device_type = (
|
||||
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
|
||||
)
|
||||
async with async_session() as session:
|
||||
session.add(
|
||||
PageView(
|
||||
path=path,
|
||||
referrer=referrer,
|
||||
user_agent=user_agent,
|
||||
browser=ua.browser.family[:40],
|
||||
os=ua.os.family[:40],
|
||||
device_type=device_type,
|
||||
ip=ip[:45],
|
||||
country=_country_for_ip(ip),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno
|
||||
|
||||
|
||||
async def _pageviews_by_day_and(session, column) -> dict[str, dict[str, int]]:
|
||||
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
|
||||
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
||||
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
|
||||
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu."""
|
||||
vday = func.date(PageView.created_at)
|
||||
recent_days = (
|
||||
select(vday.label("day"))
|
||||
.distinct()
|
||||
.order_by(vday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
||||
.where(vday.in_(select(recent_days.c.day)))
|
||||
.group_by(vday, column)
|
||||
.order_by(vday.desc())
|
||||
)
|
||||
).all()
|
||||
nested: dict[str, dict[str, int]] = {}
|
||||
for r in rows:
|
||||
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
||||
return nested
|
||||
|
||||
|
||||
async def get_daily_stats() -> dict:
|
||||
async with async_session() as session:
|
||||
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
||||
# across SQLite and Postgres and returns a plain string/date value without
|
||||
# the double-conversion issue cast(..., Date) triggers on SQLite (aiosqlite
|
||||
# already coerces TIMESTAMP columns to datetime before the Date result
|
||||
# processor tries to re-parse them as an ISO string).
|
||||
day = func.date(Game.created_at)
|
||||
game_rows = (
|
||||
await session.execute(
|
||||
select(day.label("day"), func.count().label("n"))
|
||||
.group_by(day)
|
||||
.order_by(day.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
pday = func.date(Player.created_at)
|
||||
player_rows = (
|
||||
await session.execute(
|
||||
select(pday.label("day"), func.count().label("n"))
|
||||
.group_by(pday)
|
||||
.order_by(pday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
total, finished = (
|
||||
await session.execute(select(func.count(), func.count(Game.ended_at)))
|
||||
).one()
|
||||
|
||||
avg_duration = (
|
||||
await session.execute(
|
||||
select(func.avg(func.extract("epoch", Game.ended_at - Game.created_at))).where(
|
||||
Game.ended_at.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
|
||||
total_players = (await session.execute(select(func.count(Player.id)))).scalar()
|
||||
|
||||
peak_hours = (
|
||||
await session.execute(
|
||||
select(extract("hour", Game.created_at).label("h"), func.count().label("n"))
|
||||
.group_by("h")
|
||||
.order_by("h")
|
||||
)
|
||||
).all()
|
||||
|
||||
rday = func.date(Game.created_at)
|
||||
rounds_rows = (
|
||||
await session.execute(
|
||||
select(rday.label("day"), func.count().label("n"))
|
||||
.select_from(Guess)
|
||||
.join(Game, Guess.game_id == Game.id)
|
||||
.group_by(rday)
|
||||
.order_by(rday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
|
||||
vday = func.date(PageView.created_at)
|
||||
pageview_rows = (
|
||||
await session.execute(
|
||||
select(vday.label("day"), func.count().label("n"))
|
||||
.group_by(vday)
|
||||
.order_by(vday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
top_referrers = (
|
||||
await session.execute(
|
||||
select(PageView.referrer, func.count().label("n"))
|
||||
.where(PageView.referrer != "")
|
||||
.group_by(PageView.referrer)
|
||||
.order_by(func.count().desc())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
top_paths = (
|
||||
await session.execute(
|
||||
select(PageView.path, func.count().label("n"))
|
||||
.group_by(PageView.path)
|
||||
.order_by(func.count().desc())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
browsers = (
|
||||
await session.execute(
|
||||
select(PageView.browser, func.count().label("n"))
|
||||
.group_by(PageView.browser)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
os_rows = (
|
||||
await session.execute(
|
||||
select(PageView.os, func.count().label("n"))
|
||||
.group_by(PageView.os)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
device_rows = (
|
||||
await session.execute(
|
||||
select(PageView.device_type, func.count().label("n"))
|
||||
.group_by(PageView.device_type)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
country_rows = (
|
||||
await session.execute(
|
||||
select(PageView.country, func.count().label("n"))
|
||||
.where(PageView.country != "")
|
||||
.group_by(PageView.country)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type)
|
||||
pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser)
|
||||
pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os)
|
||||
|
||||
return {
|
||||
"games_per_day": {str(r.day): r.n for r in game_rows},
|
||||
"players_per_day": {str(r.day): r.n for r in player_rows},
|
||||
"completion_rate": finished / total if total else None,
|
||||
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
||||
"total_players": total_players,
|
||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
||||
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
|
||||
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
|
||||
"pageviews_per_day_by_device": pageviews_per_day_by_device,
|
||||
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
||||
"pageviews_per_day_by_os": pageviews_per_day_by_os,
|
||||
"top_referrers": {r.referrer: r.n for r in top_referrers},
|
||||
"top_paths": {r.path: r.n for r in top_paths},
|
||||
"browsers": {r.browser: r.n for r in browsers},
|
||||
"operating_systems": {r.os: r.n for r in os_rows},
|
||||
"device_types": {r.device_type: r.n for r in device_rows},
|
||||
"countries": {r.country: r.n for r in country_rows},
|
||||
}
|
||||
Reference in New Issue
Block a user