From 7ec5782b556df86216121f51f0b78684d7696b78 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 14 Aug 2026 23:23:16 +0100 Subject: [PATCH] =?UTF-8?q?Admin=20telemetry:=20/stats=20endpoint=20(local?= =?UTF-8?q?host-only,=20404=20off-box)=20+=20per-request=20middleware=20ta?= =?UTF-8?q?gging=20client=20kind=20(X-Client=20header,=20widget-diag=20inf?= =?UTF-8?q?erence)=20and=20IP=20with=20rolling=2024h=20window;=20FUEL=5FST?= =?UTF-8?q?ATS=5FENABLED=3D0=20toggle.=20Purely=20observational=20?= =?UTF-8?q?=E2=80=94=20no=20effect=20on=20responses,=20no=20GOV.UK=20traff?= =?UTF-8?q?ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index a471b5d..1362f79 100644 --- a/app/main.py +++ b/app/main.py @@ -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