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

22 KiB
Raw Blame History

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 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 (15plus), span buckets (1ymax), 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 sinkui_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 streamingMAX_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 checkSec-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 newSCHEMA, 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 newui_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.