thermograph/UI-EVENTS.md
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

380 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# UI product-event instrumentation — design
**Status: design + prototype. Feature-flagged OFF. Not deployed anywhere.**
Enabling it in any environment requires the operator decisions in
[§6](#6-decisions-needed-from-the-operator) first.
The goal is to learn what people actually *do* in the Thermograph UI — did they
ever get a location, does search work, which views earn their maintenance — with
instrumentation that is *structurally incapable* of tracking a person, so it
needs no consent banner and contradicts nothing on the public privacy page.
---
## 0. What already exists (build on this, don't reinvent)
| Seam | What it does | Where |
|---|---|---|
| Request middleware | classifies + counts every inbound request, writes an access JSONL line **including the raw client IP** | `backend/web/app.py` `revalidate_static` |
| `core/metrics.py` | since-start counters (inbound by category, outbound by source, heartbeats) behind `GET /api/v2/metrics` | `backend/core/metrics.py` |
| **Product-event beacon** | `POST /api/v2/event`, `navigator.sendBeacon`, allowlisted names, per-IP rate limit, referrer reduced to a bare domain, always 204 | `api_event` + `metrics.record_event` |
| Event counters | `(event, referrer_domain, UTC day)` → count, **7-day** window, UNLOGGED, TRUNCATEd every deploy | `metrics.EVENTS`, `event_cume` |
| Client tracker | `track(event)` + `[data-event]` click delegation | `frontend/static/digest.js` |
| Activity JSONL | `audit.log_activity(tag, record)``logs/activity/*.jsonl` | `backend/core/audit.py` |
| Loki/Grafana | Alloy ships `/applogs/**/*.jsonl` fleet-wide, **30-day** retention | `observability/` |
So four coarse events (`home.locate`, `home.digest_signup`, `home.records_click`,
`home.share`, plus `home.nav_*`) are already live, on the SSR homepage only.
**Three gaps this design closes.**
1. **No dimensions.** `home.locate` is a bare counter. It cannot say whether the
tap *succeeded*, which is the only interesting part.
2. **No durability.** The counters are UNLOGGED and TRUNCATEd on every deploy,
and capped at 7 days. Loki holds 30 days of JSONL. Neither survives a quarter,
so no seasonal or before/after comparison is possible — which is most of the
value for a site about *weather*.
3. **Nothing outside the homepage.** Calendar, Day, Compare, Score, Alerts — the
whole interactive product — is uninstrumented.
Two corrections to assumptions that were made when this work was scoped:
Loki retention is **30 days** (`observability/loki/config.yml`, `720h`), not 24h;
and the Alloy glob `/applogs/**/*.jsonl` already picks up the `activity/` stream,
so a new JSONL sink needs no shipping config.
---
## 1. The events worth capturing
Traffic is ~9,700 req/day of which ~60% is crawlers, so **~3,000 human req/day**.
At that volume a firehose buys nothing a well-chosen aggregate doesn't, and every
extra event is another thing to keep honest. Seven events, each tied to a decision
someone would actually make:
| Event | Question it answers | Decision it informs |
|---|---|---|
| `view.open` | How many sessions reach each view, and from where? | The denominator for everything else; whether Compare/Score justify their maintenance |
| `place.pick` | Does anyone ever get past "no place picked", and by which route (geolocate / map / search / restore / link)? | **The activation question.** If geolocate is mostly declined, promote the map picker |
| `place.search` | Does location search find anything? (`hit` / `typo` / `miss`) | Whether to invest in the geocoder/typo tolerance |
| `view.control` | Which in-view controls get used — chart metric, °F/°C, date, range, season, compare add/remove, distribution metric | Which chart metrics to keep/promote; whether the unit default is wrong |
| `share` | Is the permalink / PNG export used at all? | Whether to keep maintaining the SVG→PNG exporter |
| `alert` | The alerts funnel: open → start → create, push on/off | Where subscription signup leaks |
| `deadend` | Errors the visitor actually *saw*: grade error, no data, geolocation denied, search miss, 404, offline | Bug reports nobody filed |
**Deliberately not captured** — because the request log already answers them, and
duplicating a signal means two numbers that will eventually disagree: page-view
counts, API latency/error rates, and asset traffic. Also deliberately not
captured: hover/scroll/dwell (needs a session identifier to mean anything),
map pan/zoom (very high volume, low decision value, and pan/zoom coordinates
are location data), and anything at all about *which* place was chosen.
### Two events are recorded server-side, not in the browser
`place.search` is recorded in `api_suggest`: the server already knows the answer,
every surface that searches is covered for free, and **the query string never has
to leave the request handler** — only the three-way outcome is stored. Same
argument would apply to alert creation (`accounts/api_accounts.py`). Prefer this
whenever the server can derive the fact: less client code, no forgeability, and
strictly less data in flight.
---
## 2. Schema
Small, typed, and *closed*. Three dimension slots (`view`, `prop`, `value`) plus
a referrer domain on `view.open` only. Every value is an enum from a fixed
allowlist (`backend/core/events.py` `SCHEMA`). Unknown event name → one `other`
bucket with blank dimensions; unknown value in a declared slot → `other`; a slot
an event doesn't declare → dropped entirely. **Nothing a client sends is ever
persisted verbatim.**
```
view.open view=<VIEWS> prop=direct|nav|link|restore +referrer domain
place.pick view=<VIEWS> prop=geolocate|map|search|restore|link
place.search prop=hit|typo|miss
view.control view=<VIEWS> prop=<CONTROLS> value=<VALUES>
share view=<VIEWS> prop=link|png|permalink
alert prop=open|start|create|push_on|push_off|test value=y|n
deadend view=<VIEWS> prop=grade_error|no_data|geo_denied|search_miss|not_found|offline
```
`VIEWS` = home, calendar, day, compare, score, alerts, legend, city, cities,
records, month, glossary, about, privacy.
`CONTROLS` = chart_metric, unit, date, range, season, totals, dist_metric,
comfort, compare_add, compare_remove, filter_sheet, day_step.
`VALUES` = the metric keys, `F`/`C`, `today`/`past`/`future`, count buckets
(`1`…`5plus`), span buckets (`1y`…`max`), season keys, `on`/`off`, `other`.
Numbers are bucketed **client-side** before they are sent (`5plus`, not `7`):
a raw count is a small amount of entropy, and a bucket is all the decision needs.
**Storage row:** `(hour_bucket, event, view, prop, value, referrer) → count`.
There is no row per interaction, and no column an identifier could go in.
Worst-case key space is a few hundred rows/hour; realistically ~50.
Migration note: the four live `home.*` events keep their current unflagged,
un-dimensioned behaviour so the ops endpoint's numbers don't reset when this
lands. Fold them into `view.open`/`place.pick`/`share` and retire the old names
as a follow-up, once there's overlap data to stitch the series.
---
## 3. Transport and storage
**Chosen: the existing beacon → two sinks.**
* **Transport** — extend `POST /api/v2/event` (`navigator.sendBeacon`, batched,
always 204). Already built, already excluded from the traffic counters, already
rate-limited. Batching (≤20 events, flushed on a 4 s timer and unconditionally
on `pagehide`/`visibilitychange`) matters because chart-metric taps and slider
drags arrive in bursts.
* **Durable sink** — `ui_event_hourly`, a TimescaleDB hypertable
(`backend/alembic/versions/0003_ui_events.py`): 30-day chunks, a native
retention policy, and an `ON CONFLICT … count + 1` upsert. The DB is already
TimescaleDB with hypertables and a compression/retention pattern to copy.
* **Live sink** — one `audit.log_activity("ui.event", …)` JSONL line per event.
Alloy already ships that path, so the last 30 days are queryable in Grafana
immediately without waiting on the rollup. Same allowlisted enums, nothing else.
* **The existing in-process counters stay untouched** — they remain the live
"what's happening right now" view on `/api/v2/metrics`.
**Why not the alternatives:**
* *Loki only.* 30-day retention loses every seasonal comparison — the thing a
weather product most wants. Loki is also a poor aggregation engine for
"unit switches per week over a year". Kept as the live tier, rejected as the
system of record.
* *Extend the in-process counters.* Cheapest option and no new storage, but they
are UNLOGGED, TRUNCATEd every deploy, capped at 7 days, and have exactly one
dimension. Every question in §1 needs at least two. Kept, not extended.
* *Per-event rows in a hypertable (a real firehose).* With no identifier, a row
per interaction gives you nothing an hourly aggregate doesn't — except
intra-hour timing, which no listed decision needs. It costs a table that
*could* hold a behavioural sequence, which is exactly the property that would
make the privacy analysis hard. Rejected on privacy grounds, not cost.
* *A third-party analytics SaaS (Plausible/Umami/GA).* GA is a non-starter
(US transfer, consent banner, cookies). Self-hosted Plausible/Umami would be
defensible, but it means another service to run and its own identifier model
(Plausible hashes IP+UA+salt — a pseudonymous identifier, which is precisely
the decision being deferred to the operator in §6.1). Rejected as more moving
parts for less control.
---
## 4. Abuse and bot resistance
The endpoint is public and unauthenticated on a site where 60% of traffic is
crawlers and scanners. Layered, in order of how much they actually buy:
1. **Closed allowlist** (the real defence). Nothing outside `SCHEMA` is stored;
unknown names collapse into one `other` bucket. Storage is bounded by the
*product of the enums*, not by what anyone sends. The endpoint cannot be grown
into a spam sink or a data-exfil channel.
2. **Body cap, enforced while streaming**`MAX_BODY_BYTES = 4096`; a declared
`Content-Length` past it is refused without reading, and an undeclared one
stops mid-stream. (The cap must comfortably exceed a full 20-event batch, or
the byte cap silently eats legitimate traffic instead of abusive traffic —
there is a regression test for exactly that.)
3. **Per-IP rate limit** — the existing token bucket, raised from 30/min to
**120/min** (`THERMOGRAPH_EVENT_RATE_PER_MIN`) because 30 was sized for four
coarse events and a batched interaction stream would silently truncate normal
use, biased toward the most engaged visitors. The IP is a key in a map cleared
every minute; it is never written anywhere.
4. **Soft first-party check**`Sec-Fetch-Site` must be `same-origin`/
`same-site`/`none`; a `cross-site` value is dropped, and a POST with neither
`Sec-Fetch-Site` nor `Origin` is flagged as the first bucket to look at.
Every browser that can run our JS sets this; naive scanners don't. Explicitly
**not** a security boundary — anything that bothers can spoof it.
5. **Uniform 204** — counted, rejected, over-cap, and rate-limited are
indistinguishable, so probing tells an attacker nothing.
6. **No UA-based bot filtering.** Blocking on User-Agent is a coin flip and gives
false confidence. The structural defence is that the beacon requires JS
execution and is linked from nowhere.
**Poisoning** (someone inflating a number rather than exhausting storage) cannot
be fully prevented without identity. Mitigation is cross-checking: the server's
own inbound page counts are an independent measure of `view.open`, so a
divergence between them is the alarm. Any analysis should treat these numbers as
*directional*, never as revenue-grade.
---
## 5. Privacy position
The site is EU-hosted with EU visitors, so GDPR + ePrivacy apply. The public
privacy page currently promises, verbatim: *"no analytics library, no cookies for
tracking, and no per-visitor identifier… simple aggregate counts… with nothing
tying any of it to an individual."* That is a commitment already made, and this
design is built to stay inside it rather than to renegotiate it.
**What the design does:**
* **No identifier of any kind.** No cookie, no `localStorage`/`sessionStorage`
id, no fingerprint, no hashed IP+UA. Not even the logged-in user id: the
handler never reads the session cookie, and `sendBeacon` cannot attach
credentials cross-origin anyway.
* **Nothing is written to the visitor's device**, so ePrivacy Art. 5(3)
(the "cookie rule", which covers *any* storage or access on terminal equipment,
not just cookies) is not engaged at all.
* **No IP is stored** by this feature — used as a per-minute rate-limit key and
discarded.
* **No free text, no coordinates, no URLs.** `place.pick` records *how* a
location was chosen, never where. The referrer is reduced server-side to a bare
registrable domain, never a full URL (which can carry someone's search query).
* **Aggregate-only storage.** An hour bucket and a tuple of enums; no row per
interaction, so no behavioural sequence exists to be reconstructed.
* **GPC and DNT are honoured** — a visitor signalling either sends nothing.
**Consequence to be honest about:** with no identifier there are **no unique
visitors, no sessions, no funnels, no bounce rate, no returning-visitor rate**.
`view.open` counts view opens, not people. Ratios across events
(`place.pick / view.open`) are *rates*, not *conversion*, because the numerator
and denominator can't be tied to the same visit. That is the price of not needing
consent, and it is worth it — but it must not be quietly forgotten when someone
later asks "how many users do we have?"
**Legal reading (for the operator to confirm, not for me to decide):** with no
identifier, no device storage, no cross-site data and aggregate-only retention,
this is anonymous audience measurement — it fits the CNIL exemption criteria and
the EDPB's "strictly necessary"/first-party-analytics reasoning, so **no consent
banner should be required**. Transient IP processing for rate limiting rests on
legitimate interest (Art. 6(1)(f)) and is security-necessary. No DPIA is
triggered (no systematic monitoring, no profiling, no special categories). One
line should be added to the Art. 30 record of processing.
**A pre-existing issue this surfaced, unrelated to the new events.**
`audit.log_access` writes the **raw client IP** for every non-static request into
`logs/access/*.jsonl`, which Alloy ships to Loki with 30-day retention. That is
personal data under GDPR, it is retained for 30 days fleet-wide, and it sits
awkwardly beside the privacy page's *"not logged beyond the normal web-server
request handling"*. **This design does not touch it and does not extend it**
but it should be decided on (§6.3) rather than left implicit, and it would be
odd to ship privacy-first analytics while the access log keeps raw IPs for a
month.
---
## 6. Decisions needed from the operator
Nothing below is decided in the prototype. The flag stays off until these are
answered.
**6.1 — Identifier: none, ephemeral session, or per-user?**
Recommended: **none** (what the prototype implements).
*Consequence of "none":* no unique visitors, no funnels, no retention cohorts —
counts of interactions, not of people.
*Ephemeral session id* (random, `sessionStorage`, 30 min): enables funnels and
sessions. But it is storage on the visitor's device → **ePrivacy consent banner
required**, and it makes the data pseudonymous rather than anonymous
(subject-access/erasure duties attach). Also requires rewriting the privacy page.
*Per-user when logged in:* directly links behaviour to an identified person.
Highest analytical value, highest duty, and hardest to justify for a free weather
site. Recommended answer: **never**, regardless of what is chosen for anonymous
visitors.
**6.2 — Failed search queries: capture the text or not?**
The single most useful piece of product data here is *what people searched for
when we found nothing*. Three options: (a) never capture — prototype default;
(b) capture **zero-result queries only**, server-side, normalised (lowercased,
length-capped, rejected if it contains `@`, a digit run, or a URL-ish token),
30-day retention, never joined to anything; (c) capture all queries — not
recommended, since successful queries are already answered by the `hit` counter
and a search box is a free-text field a person can type anything into.
Recommended: **(b), as a separately flagged follow-up**, with its own privacy-page
sentence. Do not bundle it with the initial rollout.
**6.3 — Raw IPs in the access log (pre-existing).**
Keep as-is / truncate to /24 (IPv4) and /48 (IPv6) / hash with a daily-rotating
salt / drop entirely. Recommended: **truncate**, which preserves the geo-scale and
abuse-pattern uses while stopping single-device identification. This is
independent of the new events but should be decided in the same pass.
**6.4 — Consent banner: rely on the analytics exemption, or add one?**
Recommended: **rely on the exemption** — it is only available while 6.1 stays
"none" and 6.2 stays "(a) or (b)". Adding a banner unlocks identifiers but costs
a real chunk of the (small) traffic to banner fatigue and adds a consent-state
machine to every page.
**6.5 — Retention for `ui_event_hourly`.**
Set in the migration (`RETENTION_DAYS`, currently **400** — a full year-over-year
comparison). Anonymous aggregates make this a storage/usefulness question rather
than a GDPR one, but it should be a deliberate number. Options: 90 / 400 /
indefinite.
**6.6 — Privacy-page copy.**
The page must keep matching reality *before* the flag goes on: the Analytics
section needs a sentence naming what is counted and stating the retention. Who
writes and approves that copy?
**6.7 — Environment separation.**
Dev and beta run the same code. Should events be recorded there at all, and if
so, tagged with the environment so they never mix into prod's numbers? (A
`host`/`env` label already exists in Loki; the hypertable has no such column.)
Recommended: **flag on for dev only during validation, then off; prod-only after.**
**6.8 — Referrer beyond `view.open`.**
The prototype keeps a referrer domain only on `view.open`. Widening it to every
event would allow "which referral source uses Compare" at the cost of a much
larger key space. Recommended: **leave as-is** until a question needs it.
**6.9 — GPC/DNT.**
The prototype honours both, biasing counts slightly downward. Confirm that trade,
or drop it.
---
## 7. Implementation plan
### Backend (`backend/`)
| File | Change | Status in prototype |
|---|---|---|
| `core/events.py` | **new**`SCHEMA`, `normalize()`, hourly upsert, JSONL sink, `ENABLED` flag | done |
| `core/metrics.py` | expose `rate_ok()` so one event spends one token across both sinks; `record_event(rate_limit=False)`; raise the per-IP ceiling to 120/min and make it env-tunable; `reset_rate_limiter()` for tests | done |
| `web/app.py` | `api_event`: batched payload, streaming body cap, `Sec-Fetch-Site` check, fan-out to both tiers, still always 204. `api_suggest`: record `place.search` server-side | done |
| `alembic/versions/0003_ui_events.py` | **new**`ui_event_hourly` hypertable + retention policy, Postgres-only (no-ops on the SQLite test bind, like `0002`) | done |
| `accounts/api_accounts.py` | record `alert` steps server-side on subscription create/delete | **todo** |
| `web/app.py` / `frontend/content.py` | record `deadend prop=not_found` on 404 responses | **todo** |
| `tests/core/test_events.py` | **new** — allowlist, slot dropping, no-free-text-by-construction, flag-off inertness, never-raises | done (10 tests) |
| `tests/web/test_event_beacon.py` | **new** — uniform 204, batch cap, oversize drop, cross-site drop, coordinates/free text never survive, legacy shape still counts | done (11 tests) |
### Frontend (`frontend/`)
| File | Change | Status |
|---|---|---|
| `static/track.js` | **new** — flag check, GPC/DNT opt-out, batching + flush on `pagehide`/`visibilitychange`, `currentView()`, `[data-event]` delegation, `trackLegacy()` for the four live events | done |
| `static/digest.js` | beacon moved out; re-exports `track`/`trackLegacy` so importers keep working | done |
| `static/app.js` | `view.open`, `place.pick` (geolocate + picker), `deadend` (geo denied, grade error, offline), `share` (link + png), `view.control chart_metric` | done |
| `static/mappicker.js` | thread a `method` ("search" vs "map") through `finish()``onPick(lat, lon, method)` | done |
| `static/units.js` | declarative `data-event` attributes on the °F/°C toggle — no import needed | done |
| `static/*.html` (6 SPA shells) | load `track.js` | done |
| `templates/base.html.j2` | stamp `data-tg-events` on `<html>` when enabled | done |
| `content.py`, `app.py` | `EVENTS_ENABLED`, applied to both HTML paths (Jinja + memoized static shells) | done |
| `static/calendar.js`, `compare.js`, `day.js`, `score.js`, `subscriptions.js` | `view.open` + their own `view.control` / `place.pick` / `alert` call sites | **todo** |
| `templates/privacy.html.j2` | Analytics-section copy update — **blocked on 6.6** | **todo** |
| `tests/unit/test_pages.py` | flag absent by default on every HTML path; stamped on both paths when enabled | done (2 tests) |
### Rollout
1. Land with `THERMOGRAPH_EVENTS` **unset everywhere**. The feature is inert:
the beacon still answers 204, nothing reaches a sink, and the flag stamp is
absent from every page (asserted by test).
2. Resolve §6. Update the privacy page. Nothing turns on before this.
3. Run the migration on dev; enable on **dev only**; verify in Grafana
(`tag="ui.event"`) that the shape is right and that no free text or coordinate
ever appears.
4. Enable on beta for a week. Compare `view.open` against the middleware's own
page counts — a large divergence means bot noise or a client bug, and is the
go/no-go for prod.
5. Enable on prod. Re-check the `other` bucket and the `Sec-Fetch-Site`-less
bucket weekly for the first month; a growing `other` means either a schema gap
or someone poking the endpoint.
6. Kill switch: unset the flag. No deploy, no migration, no data loss — existing
rows just stop growing.
### Cost check
~3,000 human req/day → on the order of 13k events/day, batched ≈ a few hundred
extra requests/day (~0.005 req/s) and a handful of upserts. Negligible against a
48 GB box; the hypertable grows by roughly a few hundred rows/day, well under
100 MB/year before compression.