All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 58s
PR build (required check) / gate (pull_request) Successful in 2s
/healthz accounted for ~47% of prod's daily access log and the internal frontend_ssr->backend content-API hop for another ~32%, each costing a threadpool dispatch + lock + file write on the request path for traffic that isn't meaningful to retain. classify_inbound gains "health" and "internal" categories for these, both excluded from audit.log_access (alongside the existing static/metrics/event exclusions) so they're never dispatched to the logging threadpool at all. The access log's client IP is now truncated to /24 (IPv4) or /48 (IPv6) before being persisted -- coarse geo/abuse signal without keeping a full, joinable address in Loki for the 30-day retention window, matching the privacy page's claim that IPs aren't logged beyond normal request handling. The rate limiter keyed off the same IP (_rate_ok) is untouched and still sees full precision. uvicorn's own --no-access-log now suppresses its duplicate per-request line, since the app's own middleware already writes one.
416 lines
18 KiB
Python
416 lines
18 KiB
Python
"""Unit tests for the in-process traffic counters (metrics.py)."""
|
|
import importlib
|
|
|
|
from core 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") == "nominatim" # forward geocoding moved off Open-Meteo
|
|
assert m.source_for_phase("history_nasa") == "nasa-power"
|
|
assert m.source_for_phase("forecast_metno") == "met-norway"
|
|
assert m.source_for_phase("history_gust") == "meteostat"
|
|
assert m.source_for_phase("meteostat_stations") == "meteostat"
|
|
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"
|
|
# The metrics endpoint is never counted, even under an unexpected prefix — e.g. the
|
|
# dashboard probing /thermograph/... against a root-served (base="") prod app.
|
|
assert c("/thermograph/api/v2/metrics", "") == "metrics"
|
|
assert c("/api/v2/metrics", "") == "metrics"
|
|
# The SSR content API (backend/api/content_routes.py) is a server-to-server
|
|
# hop from frontend_ssr's own api_client.py, never a browser -- its own
|
|
# category is what lets it be excluded from the access log without also
|
|
# hiding real external traffic under the generic "api:*" bucket.
|
|
assert c("/thermograph/api/v2/content/hub", "/thermograph") == "internal"
|
|
assert c("/api/v2/content/city/tokyo", "") == "internal"
|
|
# The liveness probe: never under BASE, whatever base the app happens to be
|
|
# mounted at (it's registered at a fixed path -- see web/app.py's healthz).
|
|
assert c("/healthz") == "health"
|
|
assert c("/healthz", "/thermograph") == "health"
|
|
# 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_metrics_endpoint_polling_is_not_counted():
|
|
# The dashboard polls the metrics endpoint; that self-traffic must be excluded.
|
|
m = _fresh()
|
|
m.record_inbound("metrics", 200)
|
|
m.record_inbound("metrics", 404)
|
|
m.record_inbound("api:place", 200)
|
|
snap = m.snapshot()
|
|
assert "metrics" not in snap["inbound"]
|
|
assert snap["inbound_total"] == 1
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_recent_window_sums_and_rolls_off(monkeypatch):
|
|
m = _fresh()
|
|
clock = {"now": 1_000_000.0}
|
|
monkeypatch.setattr(m.time, "time", lambda: clock["now"])
|
|
|
|
# minute 0: three page hits + one archive call
|
|
for _ in range(3):
|
|
m.record_inbound("page", 200)
|
|
m.record_outbound("history_fetch", "ok") # -> open-meteo-archive
|
|
snap = m.snapshot()
|
|
assert snap["window_minutes"] == 10
|
|
assert snap["inbound_recent"]["page"] == 3
|
|
assert snap["outbound_recent"]["open-meteo-archive"] == 1
|
|
|
|
# +5 min: still inside the 10-minute window, so the earlier hits still count
|
|
clock["now"] += 5 * 60
|
|
m.record_inbound("page", 200)
|
|
assert m.snapshot()["inbound_recent"]["page"] == 4
|
|
|
|
# +6 more min (t = 11 min): the minute-0 burst has rolled out of the window;
|
|
# only the hit from minute 5 remains, and the archive call is gone entirely.
|
|
clock["now"] += 6 * 60
|
|
snap = m.snapshot()
|
|
assert snap["inbound_recent"].get("page", 0) == 1
|
|
assert "open-meteo-archive" not in snap["outbound_recent"]
|
|
# the cumulative since-start total is unaffected by the rolling window
|
|
assert snap["inbound"]["page"]["total"] == 4
|
|
|
|
|
|
def test_recent_excludes_self_polling():
|
|
m = _fresh()
|
|
m.record_inbound("metrics", 200) # dashboard polling — never counted
|
|
m.record_inbound("page", 200)
|
|
snap = m.snapshot()
|
|
assert "metrics" not in snap["inbound_recent"]
|
|
assert snap["inbound_recent"]["page"] == 1
|
|
|
|
|
|
# --- shared SQLite store (multi-worker) -------------------------------------------
|
|
# When the app runs several uvicorn workers, per-process memory would split the tally
|
|
# across workers; THERMOGRAPH_METRICS_DB points them at one shared SQLite file instead.
|
|
|
|
def test_sqlite_store_matches_snapshot_shape(tmp_path):
|
|
m = _fresh()
|
|
store = m._SqliteStore(str(tmp_path / "metrics.db"))
|
|
store.record_inbound("api:place", "2xx")
|
|
store.record_inbound("api:place", "2xx")
|
|
store.record_inbound("api:place", "5xx")
|
|
store.record_outbound("open-meteo-archive", "ok")
|
|
data = store.read()
|
|
assert data["inbound"]["api:place"]["total"] == 3
|
|
assert data["inbound"]["api:place"]["2xx"] == 2
|
|
assert data["inbound"]["api:place"]["5xx"] == 1
|
|
# stable source rows are present even at zero traffic, like the in-memory store
|
|
assert data["outbound"]["open-meteo-archive"]["ok"] == 1
|
|
assert "nasa-power" in data["outbound"]
|
|
assert data["inbound_recent"]["api:place"] == 3
|
|
assert data["outbound_recent"]["open-meteo-archive"] == 1
|
|
|
|
|
|
def test_sqlite_store_is_shared_across_workers(tmp_path):
|
|
# Two store instances on the same file stand in for two worker processes: what one
|
|
# records, the other must see — that's the whole point of the shared store.
|
|
m = _fresh()
|
|
db = str(tmp_path / "metrics.db")
|
|
worker_a = m._SqliteStore(db)
|
|
worker_b = m._SqliteStore(db)
|
|
worker_a.record_inbound("page", "2xx")
|
|
worker_b.record_inbound("page", "2xx")
|
|
worker_b.record_inbound("api:grade", "2xx")
|
|
# Either worker, polled by the dashboard, reports the combined tally.
|
|
for worker in (worker_a, worker_b):
|
|
data = worker.read()
|
|
assert data["inbound"]["page"]["total"] == 2
|
|
assert data["inbound"]["api:grade"]["total"] == 1
|
|
assert data["inbound_recent"]["page"] == 2
|
|
# Both workers share one start time (first writer wins), not one-per-process.
|
|
assert worker_a.started_at == worker_b.started_at
|
|
|
|
|
|
def test_sqlite_recent_window_rolls_off(tmp_path, monkeypatch):
|
|
m = _fresh()
|
|
clock = {"now": 1_000_000.0}
|
|
monkeypatch.setattr(m.time, "time", lambda: clock["now"])
|
|
store = m._SqliteStore(str(tmp_path / "metrics.db"))
|
|
|
|
store.record_inbound("page", "2xx")
|
|
assert store.read()["inbound_recent"]["page"] == 1
|
|
# +11 min: the hit has rolled out of the 10-minute window and is pruned...
|
|
clock["now"] += 11 * 60
|
|
store.record_inbound("page", "2xx") # advances the minute -> triggers prune
|
|
data = store.read()
|
|
assert data["inbound_recent"]["page"] == 1 # only the fresh hit remains in-window
|
|
assert data["inbound"]["page"]["total"] == 2 # ...but the cumulative total is intact
|
|
|
|
|
|
def test_env_selects_sqlite_store(tmp_path, monkeypatch):
|
|
# Setting the env var makes the module boot on the shared store, not in-memory.
|
|
monkeypatch.setenv("THERMOGRAPH_METRICS_DB", str(tmp_path / "metrics.db"))
|
|
m = _fresh()
|
|
assert isinstance(m._store, m._SqliteStore)
|
|
m.record_inbound("page", 200)
|
|
assert m.snapshot()["inbound"]["page"]["total"] == 1
|
|
|
|
|
|
def test_heartbeat_snapshot_reports_age_and_interval(monkeypatch):
|
|
# A daemon stamps its liveness; the snapshot reports it as an age on the server clock
|
|
# (so a reader needn't reconcile clock skew) plus the beat's expected interval.
|
|
m = _fresh()
|
|
clock = {"now": 1_000_000.0}
|
|
monkeypatch.setattr(m.time, "time", lambda: clock["now"])
|
|
m.record_heartbeat("subscription-notifier", 900)
|
|
clock["now"] += 42 # 42 s pass before the dashboard polls
|
|
hb = m.snapshot()["heartbeats"]["subscription-notifier"]
|
|
assert hb["age_s"] == 42.0
|
|
assert hb["interval_s"] == 900
|
|
|
|
|
|
def test_heartbeat_absent_until_recorded():
|
|
# No beat yet => no entry (the dashboard renders this as DOWN / "no heartbeat").
|
|
m = _fresh()
|
|
assert m.snapshot()["heartbeats"] == {}
|
|
|
|
|
|
def test_heartbeat_shared_across_workers_sqlite(tmp_path):
|
|
# Only the leader worker beats, but every worker reads the shared store — so the
|
|
# dashboard sees the notifier alive whichever worker happens to answer its poll.
|
|
m = _fresh()
|
|
db = str(tmp_path / "metrics.db")
|
|
leader = m._SqliteStore(db)
|
|
non_leader = m._SqliteStore(db) # never beats (it isn't the notifier leader)
|
|
leader.record_heartbeat("subscription-notifier", 5_000.0, 900)
|
|
beats = non_leader.read()["heartbeats"]
|
|
assert beats["subscription-notifier"]["ts"] == 5_000.0
|
|
assert beats["subscription-notifier"]["interval_s"] == 900
|
|
|
|
|
|
def test_heartbeat_survives_store_read_shape(tmp_path):
|
|
# The in-memory and SQLite stores expose heartbeats identically (ts + interval_s).
|
|
m = _fresh()
|
|
mem = m._MemStore()
|
|
mem.record_heartbeat("subscription-notifier", 10.0, 900)
|
|
assert mem.read()["heartbeats"]["subscription-notifier"] == {"ts": 10.0, "interval_s": 900}
|
|
|
|
|
|
# --- product events -----------------------------------------------------------
|
|
# These count intent (a tap, a signup), not requests, and are keyed by
|
|
# (event, referrer domain, UTC day). The endpoint is unauthenticated, so the
|
|
# allowlist and the caps are load-bearing, not decoration.
|
|
|
|
def test_event_allowlist():
|
|
m = _fresh()
|
|
assert m.normalize_event("home.locate") == "home.locate"
|
|
assert m.normalize_event("home.nav_calendar") == "home.nav_calendar"
|
|
# Unknown names collapse into one bucket rather than growing the key space.
|
|
assert m.normalize_event("home.nav_not_a_surface") == m.EVENT_OTHER
|
|
assert m.normalize_event("totally.made.up") == m.EVENT_OTHER
|
|
assert m.normalize_event("") == m.EVENT_OTHER
|
|
assert m.normalize_event(None) == m.EVENT_OTHER
|
|
|
|
|
|
def test_referrer_normalization():
|
|
m = _fresh()
|
|
# Only the bare domain is kept — never the path or query, which can carry a
|
|
# search term or a private URL.
|
|
assert m.normalize_referrer("https://news.ycombinator.com/item?id=1") == "news.ycombinator.com"
|
|
assert m.normalize_referrer("https://www.reddit.com/r/weather/") == "reddit.com"
|
|
assert m.normalize_referrer(None) == "direct"
|
|
assert m.normalize_referrer("") == "direct"
|
|
assert m.normalize_referrer("https://thermograph.org/x", host="thermograph.org") == "self"
|
|
assert m.normalize_referrer("https://www.thermograph.org/x", host="thermograph.org") == "self"
|
|
# Junk is sanitized, never stored raw.
|
|
assert " " not in m.normalize_referrer("https://ev il<>.com/")
|
|
assert len(m.normalize_referrer("https://" + "a" * 200 + ".com/")) <= 64
|
|
|
|
|
|
def test_events_are_not_counted_as_inbound_traffic():
|
|
"""The beacon reports traffic; counting it would double every interaction."""
|
|
m = _fresh()
|
|
assert m.classify_inbound("/thermograph/api/v2/event", "/thermograph") == "event"
|
|
m.record_inbound("event", 204)
|
|
assert m.snapshot()["inbound_total"] == 0
|
|
assert "event" not in m.snapshot()["inbound"]
|
|
|
|
|
|
def test_record_event_keys_by_event_referrer_day():
|
|
m = _fresh()
|
|
for _ in range(3):
|
|
m.record_event("home.locate", referer="https://news.ycombinator.com/x")
|
|
m.record_event("home.share", referer=None)
|
|
events = m.snapshot()["events"]
|
|
day = m._utc_day()
|
|
assert events["home.locate"]["news.ycombinator.com"][day] == 3
|
|
assert events["home.share"]["direct"][day] == 1
|
|
assert m.snapshot()["events_total"] == 4
|
|
|
|
|
|
def test_unknown_event_carries_no_referrer_dimension():
|
|
m = _fresh()
|
|
m.record_event("junk.event", referer="https://spam.example/")
|
|
events = m.snapshot()["events"]
|
|
assert list(events[m.EVENT_OTHER]) == ["direct"]
|
|
|
|
|
|
def test_referrer_cardinality_is_capped():
|
|
"""A spray of forged Referer headers must not grow the key space forever."""
|
|
m = _fresh()
|
|
m._EVENT_RATE_PER_MIN = 10_000
|
|
for i in range(m.MAX_REFERRERS + 50):
|
|
m.record_event("home.locate", referer=f"https://ref{i}.example/")
|
|
refs = m.snapshot()["events"]["home.locate"]
|
|
assert len(refs) <= m.MAX_REFERRERS + 1 # +1 for the overflow bucket
|
|
assert m.REFERRER_OTHER in refs
|
|
|
|
|
|
def test_event_rate_limit_per_ip():
|
|
m = _fresh()
|
|
for _ in range(m._EVENT_RATE_PER_MIN + 25):
|
|
m.record_event("home.locate", ip="203.0.113.9")
|
|
assert m.snapshot()["events_total"] == m._EVENT_RATE_PER_MIN
|
|
|
|
|
|
# --- Postgres store: lazy connect, no permanent in-memory downgrade -------------
|
|
# Regression for the cold-boot race where the app+db containers start together, the
|
|
# app imported metrics before Postgres was accepting connections, the eager store
|
|
# build failed, and the process fell back to a per-worker in-memory store for its
|
|
# whole lifetime — fragmenting counters and hiding the notifier heartbeat.
|
|
|
|
class _FakeStore:
|
|
"""Stand-in for a connected `_SqliteStore`: records heartbeats to memory and
|
|
`read()` surfaces them in the canonical snapshot shape."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.beats = {}
|
|
|
|
def record_inbound(self, *a):
|
|
pass
|
|
|
|
def record_outbound(self, *a):
|
|
pass
|
|
|
|
def record_event(self, *a):
|
|
pass
|
|
|
|
def record_heartbeat(self, name, ts, interval_s):
|
|
self.beats[name] = {"ts": ts, "interval_s": interval_s}
|
|
|
|
def read(self):
|
|
return {"started_at": 0.0, "inbound": {}, "outbound": {},
|
|
"inbound_recent": {}, "outbound_recent": {},
|
|
"heartbeats": dict(self.beats), "events": {}, "events_recent": {}}
|
|
|
|
|
|
def test_postgres_url_uses_lazy_store_and_never_downgrades(monkeypatch):
|
|
monkeypatch.setenv("THERMOGRAPH_DATABASE_URL", "postgresql://u:p@127.0.0.1:1/x")
|
|
m = _fresh()
|
|
# Postgres is the intended store, and it is NOT connected at import time.
|
|
assert isinstance(m._store, m._LazyPgStore)
|
|
assert m._store._real is None
|
|
|
|
# Boot-time DB blip: the underlying store can't be built. Metrics must not crash,
|
|
# must not fabricate a heartbeat, and must NOT downgrade to an in-memory store.
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("postgres not ready yet")
|
|
monkeypatch.setattr(m, "_SqliteStore", _boom)
|
|
m.record_heartbeat("subscription-notifier", 900)
|
|
assert m.snapshot()["heartbeats"] == {}
|
|
assert isinstance(m._store, m._LazyPgStore) # still the PG store, not _MemStore
|
|
|
|
|
|
def test_lazy_store_connects_on_first_use_and_delegates(monkeypatch):
|
|
monkeypatch.setenv("THERMOGRAPH_DATABASE_URL", "postgresql://u:p@127.0.0.1:1/x")
|
|
m = _fresh()
|
|
monkeypatch.setattr(m, "_SqliteStore", _FakeStore)
|
|
# First use (as the notifier's first beat is, after the app's own DB init) connects.
|
|
m.record_heartbeat("subscription-notifier", 900)
|
|
assert m._store._real is not None
|
|
assert "subscription-notifier" in m.snapshot()["heartbeats"]
|
|
|
|
|
|
def test_lazy_store_retries_after_a_failed_connect(monkeypatch):
|
|
monkeypatch.setenv("THERMOGRAPH_DATABASE_URL", "postgresql://u:p@127.0.0.1:1/x")
|
|
m = _fresh()
|
|
calls = {"n": 0}
|
|
|
|
def _flaky(*a, **k):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise RuntimeError("not ready")
|
|
return _FakeStore()
|
|
monkeypatch.setattr(m, "_SqliteStore", _flaky)
|
|
|
|
m.record_heartbeat("x", 900) # first attempt fails, backoff armed
|
|
assert m._store._real is None
|
|
m.record_heartbeat("x", 900) # within backoff: no second attempt
|
|
assert calls["n"] == 1
|
|
m._store._next_try = 0.0 # backoff elapsed
|
|
m.record_heartbeat("subscription-notifier", 900)
|
|
assert calls["n"] == 2
|
|
assert m._store._real is not None
|
|
|
|
|
|
def test_boot_token_is_stable_within_a_process():
|
|
m = _fresh()
|
|
assert isinstance(m._boot_token(), float)
|
|
assert m._boot_token() == m._boot_token()
|