thermograph/backend/core/events.py
Emi Griffith 0ddc457d8c UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.

Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.

Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.

Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 15:49:55 -07:00

280 lines
12 KiB
Python

"""Product-event instrumentation: what people actually *do* in the UI.
This is the durable, dimensioned tier that sits above ``core/metrics.py``'s
since-start counters. metrics.py answers "is traffic flowing right now"; this
module answers "did anyone ever get a location, and did search work" — over
weeks, across deploys.
**Feature-flagged OFF.** Nothing here records anything unless
``THERMOGRAPH_EVENTS=1``. With the flag unset the beacon still answers 204 and
this module is inert, so shipping it is a no-op.
Design constraints, in priority order:
1. **No identifier, ever.** No cookie, no localStorage id, no fingerprint, no
user id — not even when the request carries a logged-in session. The unit of
record is an *hour bucket + a tuple of allowlisted enum values*, never a
person and never a row-per-interaction. That is a structural guarantee: there
is no column an identifier could be written into.
2. **No free text, no coordinates.** Every field is drawn from a fixed
allowlist (see ``SCHEMA``). An unrecognised name or value collapses to a
bounded "other" bucket rather than being stored. So the endpoint cannot be
turned into arbitrary storage, and cannot accidentally capture a search
query, a URL, or a lat/lon.
3. **No IP.** The caller's IP is used only as an in-memory rate-limit key for
the current minute (see ``metrics._rate_ok``) and is never passed in here.
4. **Never breaks a request.** Every entry point swallows its own errors.
Storage: an hourly aggregate upsert into ``ui_event_hourly`` (a TimescaleDB
hypertable on Postgres — see ``alembic/versions/0003_ui_events.py``), plus one
JSONL line per event into the activity stream so Loki/Grafana can show the last
24h live. Neither carries anything person-linked. Off Postgres (dev, tests,
offline tooling) the aggregate sink degrades to a no-op and only the JSONL line
is written — the same dialect split ``data/store.py`` and ``core/metrics.py``
already use.
"""
import datetime
import os
import threading
from core import audit
# --- feature flag -------------------------------------------------------------
# Default OFF everywhere. Turn on per-environment (dev first, then beta, then
# prod) once the privacy copy in frontend_ssr/templates/privacy.html.j2 has been
# updated to match what is actually recorded.
ENABLED = os.environ.get("THERMOGRAPH_EVENTS", "").strip().lower() in ("1", "true", "yes", "on")
DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
IS_POSTGRES = DATABASE_URL.startswith("postgresql")
def _libpq_dsn(url: str) -> str:
"""A plain libpq URL psycopg accepts (mirrors climate_store/metrics)."""
return url.replace("+asyncpg", "").replace("+psycopg", "")
_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""
# --- schema -------------------------------------------------------------------
# Seven events. Every one answers a product question that cannot be answered from
# the request log alone; anything the request log *can* answer (page views, API
# usage, error rates) is deliberately NOT duplicated here.
#
# Three dimension slots are stored — ``view``, ``prop``, ``value`` — plus a
# referrer domain on ``view.open`` only (acquisition), so the key space stays
# small enough to eyeball. Each event declares which slots it uses and the exact
# allowlist for each.
VIEWS = frozenset({
"home", "calendar", "day", "compare", "score", "alerts", "legend",
"city", "cities", "records", "month", "glossary", "about", "privacy",
})
#: Where a location came from. The activation question: do visitors ever get
#: past "no place picked", and by which route?
PICK_METHODS = frozenset({"geolocate", "map", "search", "restore", "link"})
#: Did the location search actually work? ``hit`` = results shown, ``typo`` =
#: results only after respelling (places.suggest's ``corrected``), ``miss`` =
#: nothing found. The query string itself is never sent or stored.
SEARCH_OUTCOMES = frozenset({"hit", "typo", "miss"})
#: In-view controls worth knowing about. Anything not listed is dropped, so a
#: new control has to be added here deliberately (and reviewed for privacy).
CONTROLS = frozenset({
"chart_metric", # value: the metric key
"unit", # value: F | C
"date", # value: today | past
"range", # value: the span bucket
"season", # value: season key or "custom"
"totals", # calendar totals mode
"dist_metric", # compare distribution metric
"comfort", # compare comfort slicer moved
"compare_add", # value: the location-count bucket after the add
"compare_remove",
"filter_sheet", # mobile filter sheet opened
"day_step", # day view prev/next
})
#: Bounded value vocabulary, shared across controls. A value outside this set is
#: stored as "other" — never verbatim.
VALUES = frozenset({
# chart / distribution metrics
"tmax", "tmin", "feels", "humid", "wind", "gust", "precip", "dry",
# units
"F", "C",
# dates
"today", "past", "future",
# spans / counts (bucketed client-side, never a raw number)
"1", "2", "3", "4", "5plus",
"1y", "2y", "5y", "10y", "max",
# seasons
"winter", "spring", "summer", "fall", "custom", "all",
# generic
"on", "off", "other",
})
SHARE_KINDS = frozenset({"link", "png", "permalink"})
#: The alerts funnel. ``ok`` records whether the step succeeded, so a high
#: start/low create ratio is visible without any per-person tracking.
ALERT_STEPS = frozenset({"open", "start", "create", "push_on", "push_off", "test"})
#: Dead ends — the highest-signal events here. A spike in any of these is a bug
#: report nobody filed.
DEADENDS = frozenset({
"grade_error", # /api/grade returned an error the user saw
"no_data", # a cell with no usable history
"geo_denied", # browser geolocation refused / timed out
"search_miss", # search returned nothing (paired with place.search=miss)
"not_found", # a 404 page was rendered
"offline", # the fetch failed with no network
})
OK = frozenset({"y", "n"})
#: name -> {slot: allowlist}. A slot absent from an event is always stored "".
SCHEMA: "dict[str, dict[str, frozenset]]" = {
# A view was opened (page load or SPA view). ``prop`` is how it was reached.
"view.open": {"view": VIEWS, "prop": frozenset({"direct", "nav", "link", "restore"})},
# A location was chosen. NEVER carries coordinates — only the route taken.
"place.pick": {"view": VIEWS, "prop": PICK_METHODS},
# A location search completed. Recorded server-side from api_suggest as well
# as client-side, so searches from every surface are covered.
"place.search": {"prop": SEARCH_OUTCOMES},
# An in-view control was used.
"view.control": {"view": VIEWS, "prop": CONTROLS, "value": VALUES},
# Share / export.
"share": {"view": VIEWS, "prop": SHARE_KINDS},
# The alerts funnel.
"alert": {"prop": ALERT_STEPS, "value": OK},
# A dead end the visitor actually hit.
"deadend": {"view": VIEWS, "prop": DEADENDS},
}
#: Anything unrecognised collapses here with every dimension blank — visible
#: enough to notice abuse, bounded enough to be harmless.
EVENT_OTHER = "other"
VALUE_OTHER = "other"
SLOTS = ("view", "prop", "value")
#: Max events per batched beacon payload. The client batches bursts (chart-metric
#: taps, slider drags) rather than firing a request each.
MAX_BATCH = 20
#: Max JSON body the beacon will read, in bytes. Must comfortably exceed
#: MAX_BATCH full-sized events (~90 bytes each) or the byte cap, not the batch
#: cap, becomes the binding limit and a legitimate full batch is silently
#: dropped. Still far too small for the endpoint to be worth abusing as storage.
MAX_BODY_BYTES = 4096
def normalize(name: str, props: "dict | None" = None) -> "tuple[str, dict[str, str]]":
"""An allowlisted ``(event, {view, prop, value})`` pair.
The allowlist is the whole security model of an unauthenticated write
endpoint: it is what stops the beacon from becoming unbounded storage, a
spam sink, or an accidental PII channel. Unknown event -> ``EVENT_OTHER``
with blank dimensions; unknown value in a known slot -> ``VALUE_OTHER``;
a slot the event does not declare -> dropped entirely.
"""
name = (name or "").strip()
spec = SCHEMA.get(name)
if spec is None:
return EVENT_OTHER, {s: "" for s in SLOTS}
props = props if isinstance(props, dict) else {}
out: "dict[str, str]" = {}
for slot in SLOTS:
allowed = spec.get(slot)
if allowed is None:
out[slot] = ""
continue
raw = props.get(slot)
raw = raw.strip() if isinstance(raw, str) else ""
out[slot] = raw if raw in allowed else (VALUE_OTHER if raw else "")
return name, out
# --- durable aggregate sink ---------------------------------------------------
# One row per (hour, event, view, prop, value, referrer). No row is ever written
# per interaction, so the table cannot hold a sequence of one person's actions
# even in principle.
_local = threading.local()
def _conn():
"""Thread-local autocommit psycopg connection, or None when Postgres is off
or unreachable. Mirrors data/climate_store.py."""
if not IS_POSTGRES:
return None
conn = getattr(_local, "conn", None)
if conn is not None and not conn.closed:
return conn
try:
import psycopg # local import: only the Postgres path needs it
_local.conn = psycopg.connect(_PG_DSN, autocommit=True)
except Exception: # noqa: BLE001 - degrade to "no durable sink", never raise
_local.conn = None
return _local.conn
def _hour_bucket() -> datetime.datetime:
now = datetime.datetime.now(datetime.timezone.utc)
return now.replace(minute=0, second=0, microsecond=0)
_UPSERT = """
INSERT INTO ui_event_hourly (bucket, event, view, prop, value, referrer, count)
VALUES (%s, %s, %s, %s, %s, %s, 1)
ON CONFLICT (bucket, event, view, prop, value, referrer)
DO UPDATE SET count = ui_event_hourly.count + 1
"""
def _upsert(event: str, dims: "dict[str, str]", referrer: str) -> None:
conn = _conn()
if conn is None:
return
try:
conn.execute(_UPSERT, (_hour_bucket(), event, dims["view"], dims["prop"],
dims["value"], referrer))
except Exception: # noqa: BLE001 - a broken connection retires itself below
try:
conn.close()
except Exception: # noqa: BLE001
pass
_local.conn = None
# --- public entry point -------------------------------------------------------
def record(name: str, props: "dict | None" = None,
referer: "str | None" = None, host: "str | None" = None) -> None:
"""Record one product event. Best-effort, silent, and a no-op unless the
feature flag is on.
``referer``/``host`` come from the request's own headers (a client-supplied
referrer would be forgeable and buys nothing) and are reduced to a bare
domain by ``metrics.normalize_referrer`` — never a full URL, which can carry
a search query or a private path. The referrer dimension is kept only for
``view.open``; every other event stores "" there, which is what keeps the
key space small.
"""
if not ENABLED:
return
try:
# Import here, not at module scope: metrics imports cleanly on its own and
# this keeps the dependency one-directional at import time.
from core import metrics
event, dims = normalize(name, props)
referrer = metrics.normalize_referrer(referer, host) if event == "view.open" else ""
_upsert(event, dims, referrer)
# Second sink: one structured line per event into the activity stream, so
# the Loki/Grafana stack can show the last 24h live without waiting on an
# hourly rollup. Carries the same allowlisted enums and nothing else.
audit.log_activity("ui.event", {"event": event, **dims, "referrer": referrer})
except Exception: # noqa: BLE001 - instrumentation must never break a request
pass