// Product-event beacon: what people actually do in the UI. // // Off by default. The server sets `data-tg-events="1"` on only when // THERMOGRAPH_EVENTS is on for that environment, so with the flag unset this // file costs one dataset read per page and sends nothing at all. // // What it will NOT send, by construction: // * no identifier of any kind — no cookie, no localStorage id, no fingerprint, // no user id even when signed in (sendBeacon cannot attach credentials // cross-origin anyway, and the server never reads the session on this route); // * no free text — never a search query, never a place name; // * no coordinates — `place.pick` records HOW a location was chosen, not where; // * no URLs — the server derives a bare referrer domain from its own headers. // Every field is an enum from a fixed allowlist mirrored server-side in // backend/core/events.py; anything else is dropped there. // // Global Privacy Control / Do Not Track are honoured: a visitor who has signalled // either sends nothing. That biases the numbers slightly downward and is worth it. // Backend's own origin + base + pinned API version, shared with account.js // rather than a second independent copy. import { uv } from "./account.js"; const URL_ = uv("event"); const optedOut = navigator.globalPrivacyControl === true || navigator.doNotTrack === "1" || window.doNotTrack === "1"; const enabled = () => document.documentElement.dataset.tgEvents === "1" && !optedOut; // Batch rather than one request per interaction: chart-metric taps and slider // drags arrive in bursts, and ~0.03 req/s of real traffic does not need a // request each. Flushed on a short timer and unconditionally when the page is // hidden or unloaded, which is the case a plain fetch() would lose. const MAX_BATCH = 20; // mirrors events.MAX_BATCH server-side const FLUSH_MS = 4000; let queue = []; let timer = 0; function send(items) { try { const body = JSON.stringify({ events: items }); if (navigator.sendBeacon) { // sendBeacon survives the page unloading, which a fetch() started on a // link click usually does not — that's the whole point for nav clicks. navigator.sendBeacon(URL_, new Blob([body], { type: "application/json" })); } else { fetch(URL_, { method: "POST", body, keepalive: true, headers: { "Content-Type": "application/json" }, }).catch(() => {}); } } catch { /* instrumentation must never break the page */ } } export function flush() { clearTimeout(timer); timer = 0; if (!queue.length) return; const items = queue; queue = []; send(items); } /** Report one product event. `props` may carry only {view, prop, value}, all * enums — see backend/core/events.py SCHEMA for the allowlists. */ export function track(event, props) { try { if (!enabled() || !event) return; queue.push(props ? { event, props } : { event }); if (queue.length >= MAX_BATCH) { flush(); return; } if (!timer) timer = setTimeout(flush, FLUSH_MS); } catch { /* never break the page */ } } // The four aggregate counters that already ship in production (metrics.EVENTS + // home.nav_*). They predate the flag and predate the schema, and the ops // endpoint's numbers must not silently reset when this file lands — so they keep // their old unconditional, unbatched behaviour until they are migrated onto the // schema above and the old names are retired. Everything NEW goes through // track() and is flag-gated. const LEGACY = /^home\.(locate|digest_signup|records_click|share|nav_[a-z]+)$/; export function trackLegacy(event) { try { if (optedOut || !event) return; send([{ event }]); } catch { /* never break the page */ } } /** Which view this page is, for the `view` dimension. Derived from the path so * no page has to declare it twice; the server re-checks it against VIEWS. */ export function currentView() { const p = location.pathname.replace(/\/+$/, ""); const last = p.slice(p.lastIndexOf("/") + 1); if (!last) return "home"; if (["calendar", "day", "compare", "score", "legend", "privacy", "about"].includes(last)) return last; if (last === "alerts") return "alerts"; if (p.includes("/climate/")) return p.split("/climate/")[1].includes("/") ? "month" : "city"; if (p.endsWith("/climate")) return "cities"; if (p.includes("/glossary")) return "glossary"; if (last === "records") return "records"; return "home"; } // pagehide covers the bfcache/navigation case; visibilitychange covers a tab // switch or the phone locking. Both fire where beforeunload is unreliable. addEventListener("pagehide", flush); addEventListener("visibilitychange", () => { if (document.hidden) flush(); }); // Anything carrying data-event reports itself when activated. Optional // data-event-prop / data-event-value carry the two extra dimensions, so a // server-rendered link needs no JS of its own. document.addEventListener("click", (e) => { const el = e.target.closest?.("[data-event]"); if (!el) return; const d = el.dataset; if (LEGACY.test(d.event)) { trackLegacy(d.event); return; } track(d.event, { view: d.eventView || currentView(), prop: d.eventProp || "", value: d.eventValue || "", }); // A click that navigates away must not lose its own event. if (el.tagName === "A") flush(); });