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.
2026-07-16 20:46:59 +00:00
|
|
|
"""Unit tests for the in-process traffic counters (metrics.py)."""
|
|
|
|
|
import importlib
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from core import metrics
|
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.
2026-07-16 20:46:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-07-23 04:29:26 +00:00
|
|
|
assert m.source_for_phase("geocode") == "nominatim" # forward geocoding moved off Open-Meteo
|
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.
2026-07-16 20:46:59 +00:00
|
|
|
assert m.source_for_phase("history_nasa") == "nasa-power"
|
|
|
|
|
assert m.source_for_phase("forecast_metno") == "met-norway"
|
2026-07-23 04:39:09 +00:00
|
|
|
assert m.source_for_phase("history_gust") == "meteostat"
|
|
|
|
|
assert m.source_for_phase("meteostat_stations") == "meteostat"
|
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.
2026-07-16 20:46:59 +00:00
|
|
|
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"
|
2026-07-16 22:58:15 +00:00
|
|
|
# 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"
|
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.
2026-07-16 20:46:59 +00:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 21:28:04 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
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.
2026-07-16 20:46:59 +00:00
|
|
|
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
|
2026-07-16 22:15:35 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
Sync main into dev (multi-worker prod + shared metrics store) (#160)
* Serve the app on thermograph.org at root; redirect emigriffith.dev/thermograph
Move Thermograph to its own domain. thermograph.org serves the app at its root,
and emigriffith.dev/thermograph* permanently redirects there (prefix stripped,
so deep links map straight across).
- app.py: BASE now supports an empty root prefix. THERMOGRAPH_BASE=/ (or empty)
yields BASE="" so routes sit at "/", "/calendar", "/api/v2/…" with no double
slashes; the bare-base redirect is skipped and the static mount falls back to
"/". Non-empty values keep the existing "/thermograph" sub-path behavior
unchanged (backward compatible).
- deploy/Caddyfile: add a thermograph.org site block reverse-proxying to the
uvicorn on 127.0.0.1:8137; turn emigriffith.dev's /thermograph handler into a
permanent redirect to thermograph.org.
- deploy/thermograph.env.example: default THERMOGRAPH_BASE=/.
- DEPLOY.md: document the two-domain layout and that deploy.sh does not ship the
Caddyfile or env — those are applied on the VPS by hand.
The frontend already uses base-relative URLs, so it follows the root base with no
changes. Verified both modes locally (root serves /, /calendar, /api/v2/*; the
/thermograph sub-path still redirects bare→slash and 404s at root).
* Add dashboard (#144)
* Auto-submit IndexNow on deploy when the URL set changes (#130)
Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.
To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.
* 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.
* Dashboard: run under the app venv, not the box's default python3 (#132)
The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.
* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)
The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.
* Dashboard: make the live view a scrollable in-place pager (#134)
The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.
--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.
* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)
The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.
* Dashboard: add a STORAGE section (archive + DB sizes) (#136)
Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.
* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)
The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.
* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)
Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.
* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)
Values below 500 MB show in MB (one decimal); 500 MB and up in GB.
* Dashboard: accounts totals only; log client IPs per request (#140)
Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.
Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.
* Dashboard: wrap-aware pager so the header stops scrolling off (#141)
The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.
* Add log storage size to the ops dashboard STORAGE section (#142)
The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.
Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.
* Dashboard: show last-10-minute traffic per category (#143)
Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.
The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".
* Add dashboard #2 (#145)
* Auto-submit IndexNow on deploy when the URL set changes (#130)
Hook the production deploy (deploy/deploy.sh) to ping IndexNow after the health
check passes, so new/changed pages reach Bing/DuckDuckGo/Yandex without a manual
run. Best-effort — wrapped so it can never fail a deploy — and it sources
/etc/thermograph.env so its key matches the one the running service serves.
To avoid re-blasting ~14k URLs on every code push, indexnow.py gains a
--if-changed mode gated by a signature of the URL set (persisted to
data/indexnow_state.txt): code-only deploys skip; a deploy that adds or removes a
city submits. `make indexnow` still forces a full submit.
* 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.
* Dashboard: run under the app venv, not the box's default python3 (#132)
The box's default python3 can be a build without _sqlite3 (pyenv 3.10 here), so
./scripts/dash crashed on import. Prefer the repo's .venv interpreter (the Python
the server itself runs on, which always has sqlite3); fall back to python3/python.
Also make the sqlite3 import in dashboard.py optional so a fallback interpreter
degrades to empty cache/accounts panels instead of crashing, and point the
'make dashboard' target at the wrapper.
* Dashboard: redraw live view in place so it stops scrolling to the bottom (#133)
The live loop did a full clear-and-reprint each refresh; when the output is taller
than the terminal (common on a phone) that scrolls the view to the bottom every tick.
Redraw from the top instead: hide the cursor, clear once, then each frame move to home
and rewrite line-by-line (clear-to-EOL + clear-below), capping the body to the window
height so it never prints more lines than fit and never scrolls. Restore the cursor on
exit; piped (non-TTY) output prints plain frames.
* Dashboard: make the live view a scrollable in-place pager (#134)
The live mode is now an alt-screen pager (like top/less): it shows only the latest
snapshot, you scroll it with swipe/arrows/jk/PgUp-PgDn/g/G, and each refresh replaces
the page in place while keeping your scroll position — nothing gets appended below and
the view is never yanked to the top or bottom. On exit it restores the cursor, leaves
the alternate screen, and resets the terminal mode.
--once still prints a single static snapshot (also used automatically when stdout/stdin
aren't a terminal, e.g. piping); --json unchanged.
* Metrics: don't count the dashboard's own /api/v2/metrics polling (#135)
The ops dashboard polls the metrics endpoint every few seconds; counting those hits
just shows the monitor watching itself and inflates the inbound totals. Skip the
'metrics' category in record_inbound (so it's excluded from inbound_total too), and
hide it in the dashboard's inbound list defensively.
* Dashboard: add a STORAGE section (archive + DB sizes) (#136)
Show on-disk storage: the parquet archives (with history/recent split), the derived
SQLite DB, and the accounts SQLite DB individually, plus a total. Archive bytes are
summed during the existing TTL-cached cache scan; DB sizes include the -wal/-shm
sidecars (WAL holds recent writes, so the base file alone understates the footprint).
Drop the now-redundant 'payload db' line from CACHE.
* Dashboard: show STORAGE sizes in fixed GB (0.01 GB floor) (#137)
The auto-scaling formatter only switched to GB once a value passed 1 GB, so the
archives read as MB and the DBs as KB. Format the STORAGE section in fixed GB with two
decimals and a 0.01 GB floor (so the small databases stay visible instead of rounding
to 0.00). Memory readouts keep the auto-scaling formatter.
* Dashboard: show STORAGE under 50 MB in MB, GB above (#138)
Values below 50 MB now render as MB with one decimal (no KB), so the databases read
naturally (derived 7.7MB, accounts 0.4MB) instead of 0.01GB; 50 MB and up stay in GB
with two decimals. Renames the helper _gb -> _storage_size to match.
* Dashboard: move STORAGE MB->GB cutoff to 500 MB (#139)
Values below 500 MB show in MB (one decimal); 500 MB and up in GB.
* Dashboard: accounts totals only; log client IPs per request (#140)
Dashboard ACCOUNTS section now shows only totals (users, active/total subscriptions,
notifications, push subs) — the per-user and per-subscription lists are gone, and the
dashboard no longer queries that PII at all.
Backend: add a per-request access log (logs/access/access-<date>.jsonl) recording the
client IP (real IP via the left-most X-Forwarded-For hop when proxied behind Caddy,
else the peer address), method, path, status, and category. Static assets and the
dashboard's own metrics polling are skipped. Best-effort, never raises into the request
path — retained for later traffic/IP analysis.
* Dashboard: wrap-aware pager so the header stops scrolling off (#141)
The pager counted one screen row per logical line, but on a narrow phone many lines
wrap to two rows — so a page printed more physical rows than the terminal had, scrolling
the top (title + CACHE header) off-screen with no way to scroll back to it. Measure each
line's wrapped height and fit a page to the real physical-row budget; page/End step by
what's actually visible; read the true tty size (not shutil, which honors stale
COLUMNS/LINES). Status line is clipped to one row.
* Add log storage size to the ops dashboard STORAGE section (#142)
The STORAGE section reported parquet archives and both SQLite DBs but
omitted the logs/ footprint, so the "total" understated real disk use.
Add a recursive _tree_size helper and a "logs" row measuring the whole
logs/ tree (audit + errors + access JSONL plus stray *.log files), broken
out by stream, and fold it into the STORAGE total. Flows through --json
automatically via read_storage.
* Dashboard: show last-10-minute traffic per category (#143)
Add a rolling short-window view alongside the since-start totals. metrics.py
keeps per-minute buckets (epoch-minute -> {key: count}) for inbound categories
and outbound sources, summed over the last 10 minutes on snapshot() and exposed
as inbound_recent / outbound_recent / window_minutes. Cheap and bounded — at most
WINDOW+1 tiny dicts, pruned as minutes roll off; self-polling still excluded.
The dashboard's TRAFFIC section now renders that count as a dim column next to
each row's total (header reads "total · last 10m"); idle sources show "-".
* Run prod on 3 uvicorn workers with a shared metrics store
A single slow upstream weather fetch (cache-miss during an open-meteo/NASA
degradation) blocked the one uvicorn worker and took the whole app down for
~2 min — real users got aborted connections. Give prod concurrency headroom so
one slow request can't freeze the rest.
- deploy/thermograph.service: worker count is env-driven (`--workers ${WORKERS}`,
default 1); prod sets WORKERS=3 in /etc/thermograph.env. Dev's separate
--user unit is untouched (stays single-worker).
- metrics.py: with multiple workers, per-process counters would split the tally
and the ops dashboard (polls one random worker) would see only a fraction.
Add a shared SQLite store selected by THERMOGRAPH_METRICS_DB so every worker
tallies into one place; unset keeps the zero-dependency in-memory store for
dev/tests/single-worker. Public API and snapshot shape unchanged.
- The unit defaults THERMOGRAPH_METRICS_DB and clears it on each (re)start via
ExecStartPre, so bumping WORKERS can never silently fragment the dashboard and
"since start" tallies keep their old meaning.
- Tests: cover the SQLite store — cross-worker aggregation, window roll-off,
and env-based selection. Full suite: 178 passed.
Note: applying to prod also needs the unit reinstalled + WORKERS=3 in
/etc/thermograph.env — deploy.sh only pulls+restarts, it doesn't reinstall the
systemd unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mRqdHWRdUjEvEdfwig3wg
---------
Co-authored-by: root <root@vmi3417050.contaboserver.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:43:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
Report the notifier's health by heartbeat, not per-worker threads (#166)
The ops dashboard marked subscription-notifier DOWN ~2/3 of the time after the
notifier was gated to a single leader worker. It judged the daemon by whether
its thread was present on whichever worker answered /api/v2/metrics, but only
the leader runs it — so polls landing on the other two workers saw no thread and
reported DOWN for a perfectly healthy notifier. Per-worker thread checks can't
describe a single-leader daemon, and worse, they could no longer distinguish a
real crash from a normal non-leader.
Report it by liveness instead: the notifier stamps a heartbeat (timestamp +
expected interval) into the shared metrics store each loop iteration, including
once at startup so the dashboard shows it up immediately after a restart. Since
the beat lives in the shared SQLite store, any worker's metrics response carries
it, so the dashboard sees the notifier's health whichever worker it polls.
- metrics.py: both stores record/expose heartbeats (SQLite reuses the meta
table, so every worker's beats share one file); snapshot() converts each beat
to a server-computed age so readers needn't reconcile clock skew.
- notify.py: beat at loop start and every wake.
- dashboard.py: judge heartbeat daemons by freshness (up to 2 missed intervals
before DOWN, with beat age shown); neighbor-warmer keeps its per-worker thread
check, which is correct since every worker runs it.
- Tests: heartbeat store/snapshot shape + cross-worker sharing; dashboard
fresh/stale/missing rendering and the unchanged warmer check. 194 passed.
Co-authored-by: root <root@vmi3417050.contaboserver.net>
2026-07-17 13:49:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
2026-07-21 15:52:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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()
|