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:
|
# Endpoints:
|
||||||
# GET /health — cache status + last sync timestamp
|
# 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 — full station list with current prices (keyless)
|
||||||
# GET /api/v1/stations?fuel=e10&lat=53.72&lng=-1.85&radius=10
|
# GET /api/v1/stations?fuel=e10&lat=53.72&lng=-1.85&radius=10
|
||||||
# — optional server-side filter/sort (cheapest first)
|
# — optional server-side filter/sort (cheapest first)
|
||||||
@@ -34,12 +36,13 @@
|
|||||||
# - Fuel types: E10, E5, B7_STANDARD (diesel), B10, HVO, SDV, ...
|
# - Fuel types: E10, E5, B7_STANDARD (diesel), B10, HVO, SDV, ...
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from collections import deque
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config (all via environment; never hardcode credentials)
|
# Config (all via environment; never hardcode credentials)
|
||||||
@@ -102,6 +105,23 @@ _state = {
|
|||||||
"data_updated": None, # freshest govUK price_last_updated across dataset
|
"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)
|
# 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 = 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
|
# 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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user