Admin telemetry: /stats endpoint (localhost-only, 404 off-box) + per-request middleware tagging client kind (X-Client header, widget-diag inference) and IP with rolling 24h window; FUEL_STATS_ENABLED=0 toggle. Purely observational — no effect on responses, no GOV.UK traffic

This commit is contained in:
FuelBoard Contributor
2026-08-14 23:23:16 +01:00
parent 6a5827561b
commit 7ec5782b55
+105 -1
View File
@@ -19,6 +19,8 @@
#
# Endpoints:
# GET /health — cache status + last sync timestamp
# GET /stats — admin telemetry (localhost only): client hits by
# kind/IP/route over a rolling 24 h window
# GET /api/v1/stations — full station list with current prices (keyless)
# GET /api/v1/stations?fuel=e10&lat=53.72&lng=-1.85&radius=10
# — optional server-side filter/sort (cheapest first)
@@ -34,12 +36,13 @@
# - Fuel types: E10, E5, B7_STANDARD (diesel), B10, HVO, SDV, ...
from contextlib import asynccontextmanager
from collections import deque
import os
import time
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Request
# ---------------------------------------------------------------------------
# Config (all via environment; never hardcode credentials)
@@ -102,6 +105,23 @@ _state = {
"data_updated": None, # freshest govUK price_last_updated across dataset
}
# ---------------------------------------------------------------------------
# Admin telemetry (inbound connections only — purely observational, no effect
# on responses and no GOV.UK traffic). Aggregates who connects, how often and
# from where so the developer can monitor the app/widget poll model. Toggle:
# FUEL_STATS_ENABLED=0 disables collection entirely.
# ---------------------------------------------------------------------------
_stats_enabled = os.environ.get("FUEL_STATS_ENABLED", "1") == "1"
_stats_started = time.time()
_stats = {
"hits": 0,
"by_client": {}, # client kind ("app" | "widget" | "siri" | "admin" | "unknown") -> count
"by_route": {}, # request path -> count
"by_ip": {}, # client IP -> {"hits": n, "last_seen": epoch}
"recent": deque(maxlen=2000), # (epoch, ip, client, path) — rolling 24 h window
}
# ---------------------------------------------------------------------------
# Normalisation helpers (shared by CSV fallback and API path)
# ---------------------------------------------------------------------------
@@ -512,6 +532,36 @@ async def lifespan(_: FastAPI):
app = FastAPI(title="FuelBoard Relay API", version="1.0.0", lifespan=lifespan)
@app.middleware("http")
async def _record_stats(request: Request, call_next):
"""Admin telemetry — tag every request with client kind + IP. The X-Client
header is set by the app ("app") and widget extension ("widget"); Siri
answers from the cached dataset and never fetches. Untagged requests fall
back to path inference (widget-diag beacon) or "unknown". Purely
observational — no effect on responses (see FUEL_STATS_ENABLED toggle)."""
response = await call_next(request)
if _stats_enabled:
path = request.url.path
client = (request.headers.get("x-client") or "").lower()
if not client:
if path == "/api/v1/widget-diag":
client = "widget"
elif path in ("/stats", "/health"):
client = "admin"
else:
client = "unknown"
ip = request.client.host if request.client else "?"
now = time.time()
_stats["hits"] += 1
_stats["by_client"][client] = _stats["by_client"].get(client, 0) + 1
_stats["by_route"][path] = _stats["by_route"].get(path, 0) + 1
ipstat = _stats["by_ip"].setdefault(ip, {"hits": 0, "last_seen": 0.0})
ipstat["hits"] += 1
ipstat["last_seen"] = now
_stats["recent"].append((now, ip, client, path))
return response
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -635,6 +685,60 @@ def stations(
}
@app.get("/api/v1/widget-diag")
async def widget_diag(kind: str = "", source: str = "", n: str = "", fuel: str = "",
sort: str = "", fav: str = "", station: str = "", intent: str = ""):
"""Widget diagnostics beacon — the widget extension fire-and-forgets a GET
here at the end of every makeEntry so the relay log records whether a
widget timeline actually ran in the extension and what it produced.
Logged via the access log; returns 204 (no body)."""
from fastapi import Response
return Response(status_code=204)
@app.get("/stats")
def stats(request: Request):
"""Admin telemetry — LOCALHOST ONLY (404 for LAN/Tailscale clients so the
endpoint is indistinguishable from a non-existent route to probes).
Aggregates inbound client traffic over a rolling 24 h window (the last
2000 requests): total hits, hits by client kind (app/widget/siri/admin/
unknown), by route, and per client IP with last-seen. Purely observational
— see the _record_stats middleware and FUEL_STATS_ENABLED toggle."""
host = request.client.host if request.client else ""
if host not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=404, detail="Not found")
now = time.time()
recent = [r for r in _stats["recent"] if now - r[0] <= 86400]
by_client: dict = {}
by_route: dict = {}
by_ip: dict = {}
for ts, ip, client, path in recent:
by_client[client] = by_client.get(client, 0) + 1
by_route[path] = by_route.get(path, 0) + 1
ipstat = by_ip.setdefault(ip, {"hits": 0, "last_seen": 0.0})
ipstat["hits"] += 1
ipstat["last_seen"] = max(ipstat["last_seen"], ts)
def _fmt_ts(epoch: float) -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch))
return {
"enabled": _stats_enabled,
"started_at": _fmt_ts(_stats_started),
"uptime_seconds": int(now - _stats_started),
"total_hits": _stats["hits"],
"hits_24h": len(recent),
"window": "rolling 24h of last 2000 requests",
"by_client": {k: by_client[k] for k in sorted(by_client, key=lambda k: -by_client[k])},
"by_route": {k: by_route[k] for k in sorted(by_route, key=lambda k: -by_route[k])},
"by_ip": {
ip: {"hits": s["hits"], "last_seen": _fmt_ts(s["last_seen"])}
for ip, s in sorted(by_ip.items(), key=lambda kv: -kv[1]["hits"])
},
}
if __name__ == "__main__":
import uvicorn