Add ops metrics endpoint + SSH/Termux dashboard (#131)

Adds observability for the running app, viewable over SSH (e.g. Termux):

- backend/metrics.py: thread-safe, best-effort in-process counters for inbound
  requests (per category) and outbound calls (per external source), plus a
  phase->source map and snapshot(). Hooked into climate._request (outbound, all
  six weather/geocode sources) and the existing revalidate_static middleware
  (inbound). Never raises into the request path.
- GET /api/v2/metrics: token-gated JSON of the counters + warm-queue depth +
  thread names/uptime/pid. Returns 404 unless a valid THERMOGRAPH_METRICS_TOKEN
  is presented or the request is direct loopback with no proxy X-Forwarded-*
  header, so ops data is never exposed through Caddy on the public domain.
- scripts/dashboard.py (+ scripts/dash, make dashboard): stdlib-only terminal
  dashboard. Reads cache (parquet + SQLite), accounts (users + active
  subscriptions), and system resources (/proc + systemd) directly, and pulls
  traffic from the metrics endpoint over loopback. Live refresh, --once, --json;
  mobile-terminal width. Paths resolve relative to the script so the same tool
  works on the LAN dev and prod VPS checkouts.
- Monitoring sections in DEPLOY.md / DEPLOY-DEV.md; unit tests for the counters.
This commit is contained in:
Emi Griffith 2026-07-16 13:46:59 -07:00 committed by GitHub
parent f5d9f33b5b
commit d6a62cc2de
4 changed files with 258 additions and 0 deletions

35
app.py
View file

@ -3,6 +3,7 @@ import contextlib
import datetime import datetime
import functools import functools
import hashlib import hashlib
import hmac
import json import json
import mimetypes import mimetypes
import os import os
@ -21,6 +22,7 @@ import climate
import content import content
import db import db
import grid import grid
import metrics
import notify import notify
import places import places
import store import store
@ -149,6 +151,10 @@ async def revalidate_static(request, call_next):
deploy immediately (no "my change isn't showing" bugs).""" deploy immediately (no "my change isn't showing" bugs)."""
response = await call_next(request) response = await call_next(request)
path = request.url.path path = request.url.path
try:
metrics.record_inbound(metrics.classify_inbound(path, BASE), response.status_code)
except Exception: # noqa: BLE001 - never let instrumentation break a response
pass
pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend", f"{BASE}/alerts") pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/compare", f"{BASE}/legend", f"{BASE}/alerts")
if path.endswith((".js", ".css", ".html")) or path in pages: if path.endswith((".js", ".css", ".html")) or path in pages:
response.headers["Cache-Control"] = "no-cache" response.headers["Cache-Control"] = "no-cache"
@ -537,6 +543,34 @@ def api_cell(
return _json_response(_encode(payload), etag) return _json_response(_encode(payload), etag)
# --- ops metrics (gated) ---------------------------------------------------
def _metrics_allowed(request: Request) -> bool:
"""Guard the ops metrics: a valid token allows from anywhere (for SSH tunnels);
with no token it's direct-loopback-only, and any forwarded header (i.e. proxied
via Caddy) is refused so prod ops data never leaks through the public domain."""
token = os.environ.get("THERMOGRAPH_METRICS_TOKEN", "").strip()
if token:
supplied = request.headers.get("x-metrics-token") or request.query_params.get("token") or ""
if hmac.compare_digest(supplied, token):
return True
client = request.client.host if request.client else None
forwarded = any(h in request.headers for h in
("x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"))
return client in ("127.0.0.1", "::1") and not forwarded
def api_metrics(request: Request):
"""Live in-process traffic counters + worker facts for the ops dashboard."""
if not _metrics_allowed(request):
raise HTTPException(status_code=404) # 404, not 403 — don't reveal the route
snap = metrics.snapshot()
snap["warm_queue_depth"] = _warm_queue.qsize()
snap["warm_seen"] = len(_WARM_SEEN)
snap["threads"] = threading.active_count()
snap["thread_names"] = sorted(t.name for t in threading.enumerate())
return snap
# --- API versioning -------------------------------------------------------- # --- API versioning --------------------------------------------------------
# Non-backward-compatible additions land in a new version; every version stays # Non-backward-compatible additions land in a new version; every version stays
# mounted and served simultaneously. v1 is the original contract (grade/geocode); # mounted and served simultaneously. v1 is the original contract (grade/geocode);
@ -555,6 +589,7 @@ v2.add_api_route("/calendar", api_calendar, methods=["GET"])
v2.add_api_route("/day", api_day, methods=["GET"]) v2.add_api_route("/day", api_day, methods=["GET"])
v2.add_api_route("/forecast", api_forecast, methods=["GET"]) v2.add_api_route("/forecast", api_forecast, methods=["GET"])
v2.add_api_route("/cell", api_cell, methods=["GET"]) v2.add_api_route("/cell", api_cell, methods=["GET"])
v2.add_api_route("/metrics", api_metrics, methods=["GET"], include_in_schema=False)
app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible) app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 (backward compatible)
app.include_router(v1, prefix=f"{BASE}/api/v1") app.include_router(v1, prefix=f"{BASE}/api/v1")

View file

@ -15,6 +15,7 @@ import numpy as np
import polars as pl import polars as pl
import audit import audit
import metrics
import store import store
CACHE_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "cache") CACHE_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "cache")
@ -154,12 +155,15 @@ def _request(url, params, timeout, *, phase, headers=None, attempts=MAX_ATTEMPTS
try: try:
r = httpx.get(url, params=params, timeout=timeout, headers=headers) r = httpx.get(url, params=params, timeout=timeout, headers=headers)
r.raise_for_status() r.raise_for_status()
metrics.record_outbound(phase, "ok")
return r return r
except Exception as e: # noqa: BLE001 - upstream/network failures are expected except Exception as e: # noqa: BLE001 - upstream/network failures are expected
last = e last = e
status = getattr(getattr(e, "response", None), "status_code", None) status = getattr(getattr(e, "response", None), "status_code", None)
rate_limited = status == 429 rate_limited = status == 429
final = rate_limited or attempt == attempts final = rate_limited or attempt == attempts
metrics.record_outbound(
phase, "rate_limited" if rate_limited else ("error" if final else "retry"))
audit.log_event( audit.log_event(
"error" if final else "retry", "error" if final else "retry",
{"phase": phase, "attempt": attempt, "max_attempts": attempts, {"phase": phase, "attempt": attempt, "max_attempts": attempts,

138
metrics.py Normal file
View file

@ -0,0 +1,138 @@
"""In-process traffic counters for the ops dashboard.
Cheap, thread-safe, best-effort counters for **inbound** HTTP requests (bucketed by
category) and **outbound** calls to external data sources (bucketed by source).
Everything here is since-process-start; durable daily history lives in the
audit/error JSONL logs (see ``audit.py``). Recording never raises into the request
path.
Read over the gated ``GET {BASE}/api/v2/metrics`` route (see ``app.py``) and rendered
by ``scripts/dashboard.py`` over SSH.
"""
import os
import threading
import time
# The ``phase=`` tag passed to ``climate._request`` maps 1:1 onto an external source.
# Keep this in sync with the phase names used at each call site in ``climate.py``.
_PHASE_SOURCE = {
"history_fetch": "open-meteo-archive",
"history_topup": "open-meteo-archive",
"recent_forecast_fetch": "open-meteo-forecast",
"geocode": "open-meteo-geocoding",
"history_nasa": "nasa-power",
"forecast_metno": "met-norway",
"reverse_geocode": "nominatim",
}
# Every source we know about, so the dashboard shows a stable set of rows even before
# any traffic has hit a given source.
SOURCES = sorted(set(_PHASE_SOURCE.values()))
_OUTCOMES = ("ok", "retry", "error", "rate_limited")
_STATUS_BUCKETS = ("2xx", "3xx", "4xx", "5xx", "other")
_lock = threading.Lock()
_started_at = time.time()
_inbound: "dict[str, dict[str, int]]" = {}
_outbound: "dict[str, dict[str, int]]" = {
s: {o: 0 for o in _OUTCOMES} for s in SOURCES
}
def source_for_phase(phase: str) -> str:
"""Map a ``climate._request`` phase tag to an external-source label."""
return _PHASE_SOURCE.get(phase, "other")
def _status_bucket(status) -> str:
try:
code = int(status)
except (TypeError, ValueError):
return "other"
if 200 <= code < 300:
return "2xx"
if 300 <= code < 400:
return "3xx"
if 400 <= code < 500:
return "4xx"
if 500 <= code < 600:
return "5xx"
return "other"
def classify_inbound(path: str, base: str = "") -> str:
"""Bucket a request path into a dashboard category.
``path`` is ``request.url.path`` (still carrying the ``BASE`` prefix); ``base`` is
the app's mount prefix (e.g. ``/thermograph`` or ``""``).
"""
p = path or "/"
if base and p.startswith(base):
p = p[len(base):] or "/"
if p.startswith("/api/"):
rest = p[len("/api/"):]
if rest.startswith(("v1/", "v2/")):
rest = rest[3:]
seg = rest.split("/", 1)[0].split("?", 1)[0]
if seg in ("auth", "users"):
return "auth"
if seg in ("subscriptions", "notifications", "push"):
return "accounts"
if seg == "metrics":
return "metrics"
return f"api:{seg}" if seg else "api:other"
if p.endswith((".js", ".css", ".html", ".webmanifest", ".png", ".svg", ".ico",
".json", ".woff", ".woff2", ".map", ".txt", ".xml")):
# robots.txt / sitemap.xml are crawler SEO endpoints, not page views.
if p in ("/robots.txt", "/sitemap.xml"):
return "seo"
return "static"
if p in ("", "/", "/calendar", "/day", "/compare", "/legend", "/alerts"):
return "page"
if p == "/about" or p.startswith("/climate") or p.startswith("/glossary"):
return "page"
return "other"
def record_inbound(category: str, status) -> None:
"""Count one inbound request in ``category``, bucketed by status class."""
try:
bucket = _status_bucket(status)
with _lock:
row = _inbound.get(category)
if row is None:
row = _inbound[category] = {"total": 0, **{b: 0 for b in _STATUS_BUCKETS}}
row["total"] += 1
row[bucket] = row.get(bucket, 0) + 1
except Exception: # noqa: BLE001 - instrumentation must never break a request
pass
def record_outbound(phase: str, outcome: str) -> None:
"""Count one outbound call. ``outcome`` in {ok, retry, error, rate_limited}."""
try:
source = source_for_phase(phase)
with _lock:
row = _outbound.get(source)
if row is None:
row = _outbound[source] = {o: 0 for o in _OUTCOMES}
row[outcome] = row.get(outcome, 0) + 1
except Exception: # noqa: BLE001
pass
def snapshot() -> dict:
"""A JSON-serializable copy of all counters plus process facts."""
with _lock:
inbound = {k: dict(v) for k, v in _inbound.items()}
outbound = {k: dict(v) for k, v in _outbound.items()}
return {
"pid": os.getpid(),
"started_at": _started_at,
"uptime_s": round(time.time() - _started_at, 1),
"inbound": inbound,
"outbound": outbound,
"inbound_total": sum(v.get("total", 0) for v in inbound.values()),
"outbound_total": sum(sum(v.values()) for v in outbound.values()),
}

81
tests/test_metrics.py Normal file
View file

@ -0,0 +1,81 @@
"""Unit tests for the in-process traffic counters (metrics.py)."""
import importlib
import metrics
def _fresh():
# Counters are module-level; reload for an isolated starting state per test.
return importlib.reload(metrics)
def test_phase_source_map_covers_every_source():
m = _fresh()
assert m.source_for_phase("history_fetch") == "open-meteo-archive"
assert m.source_for_phase("history_topup") == "open-meteo-archive"
assert m.source_for_phase("recent_forecast_fetch") == "open-meteo-forecast"
assert m.source_for_phase("geocode") == "open-meteo-geocoding"
assert m.source_for_phase("history_nasa") == "nasa-power"
assert m.source_for_phase("forecast_metno") == "met-norway"
assert m.source_for_phase("reverse_geocode") == "nominatim"
assert m.source_for_phase("unknown-phase") == "other"
def test_classify_inbound_categories():
m = _fresh()
c = m.classify_inbound
# BASE stripped, API namespaces
assert c("/thermograph/api/v2/place", "/thermograph") == "api:place"
assert c("/thermograph/api/v2/grade", "/thermograph") == "api:grade"
assert c("/thermograph/api/v1/geocode", "/thermograph") == "api:geocode"
assert c("/thermograph/api/v2/auth/login", "/thermograph") == "auth"
assert c("/thermograph/api/v2/users/me", "/thermograph") == "auth"
assert c("/thermograph/api/v2/subscriptions", "/thermograph") == "accounts"
assert c("/thermograph/api/v2/notifications", "/thermograph") == "accounts"
assert c("/thermograph/api/v2/push/subscribe", "/thermograph") == "accounts"
assert c("/thermograph/api/v2/metrics", "/thermograph") == "metrics"
# pages, static, seo
assert c("/thermograph/", "/thermograph") == "page"
assert c("/thermograph/calendar", "/thermograph") == "page"
assert c("/thermograph/climate/tokyo", "/thermograph") == "page"
assert c("/thermograph/app.js", "/thermograph") == "static"
assert c("/thermograph/style.css", "/thermograph") == "static"
assert c("/thermograph/robots.txt", "/thermograph") == "seo"
# root-mounted (BASE == "")
assert c("/api/v2/place", "") == "api:place"
assert c("/", "") == "page"
def test_record_inbound_buckets_by_status():
m = _fresh()
m.record_inbound("api:place", 200)
m.record_inbound("api:place", 200)
m.record_inbound("api:place", 503)
m.record_inbound("page", 304)
snap = m.snapshot()
assert snap["inbound"]["api:place"]["total"] == 3
assert snap["inbound"]["api:place"]["2xx"] == 2
assert snap["inbound"]["api:place"]["5xx"] == 1
assert snap["inbound"]["page"]["3xx"] == 1
assert snap["inbound_total"] == 4
def test_record_outbound_by_source_and_outcome():
m = _fresh()
m.record_outbound("history_fetch", "ok")
m.record_outbound("history_topup", "ok") # same source (archive)
m.record_outbound("reverse_geocode", "error")
m.record_outbound("recent_forecast_fetch", "rate_limited")
snap = m.snapshot()
assert snap["outbound"]["open-meteo-archive"]["ok"] == 2
assert snap["outbound"]["nominatim"]["error"] == 1
assert snap["outbound"]["open-meteo-forecast"]["rate_limited"] == 1
assert snap["outbound_total"] == 4
def test_snapshot_has_process_facts():
m = _fresh()
snap = m.snapshot()
assert isinstance(snap["pid"], int)
assert snap["uptime_s"] >= 0
assert "open-meteo-archive" in snap["outbound"] # stable source rows exist