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:
+105
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user