// 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. // // TWO TIERS, and the difference is the whole privacy story: // // Tier A — anonymous. Every visitor, no consent needed, because nothing is // stored on the device and nothing identifies anyone. The server keeps hourly // counts of allowlisted enums. // // Tier B — session-scoped. Adds an ephemeral id so a funnel ("did the visitor // who opened the picker reach a grade?") can be computed. It requires opt-in // consent (see consent.js) because it writes an identifier to sessionStorage. // Without consent, `sid()` returns "" and NOTHING is written to the device — // Tier A still reports, so refusing costs the product its funnels, not its // numbers. // // What this NEVER sends, in either tier: // * no stable or cross-site identifier — the Tier B id is per-tab, rotates on // idle/absolute caps, and is never linked to another session, device, or to // the signed-in account (sendBeacon cannot attach credentials cross-origin, // and the server does not read the session cookie on that route); // * no free text — never a search query, never a place name. Failed searches // are captured server-side only, never from here; // * 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. // Backend's own origin + base + pinned API version, shared with account.js // rather than a second independent copy. import { uv } from "./account.js"; import { hasConsent, onConsentChange, signalledOptOut } from "./consent.js"; const URL_ = uv("event"); const flagged = () => document.documentElement.dataset.tgEvents === "1"; // Tier A: the flag, and an explicit browser-level opt-out signal. GPC/DNT are // honoured here too even though Tier A needs no consent — a visitor who has // asked not to be measured should not be measured, and the resulting downward // bias is small and worth it. const enabled = () => flagged() && !signalledOptOut; // --- Tier B: the ephemeral session id ------------------------------------------ // sessionStorage, not localStorage and not a cookie: // * localStorage would persist across sessions and make the id a stable // pseudonym — the exact thing being avoided; // * a cookie would ride along on every request including static assets, be // readable server-side on routes that have nothing to do with analytics, // and buy nothing here. // sessionStorage is per-tab and dies with the tab. Consequence, stated honestly: // a visitor who opens Compare in a second tab is two sessions, so funnels // UNDERCOUNT completion. That is the error direction to prefer. const SID_KEY = "thermograph:sid"; const IDLE_MS = 30 * 60 * 1000; // rotate after 30 minutes of inactivity const MAX_MS = 2 * 60 * 60 * 1000; // and after 2 hours regardless — a hard cap // on how long any one behavioural sequence // can possibly be const MAX_SEQ = 200; // mirrors events.MAX_SEQ server-side function mintId() { // 16 random bytes -> 22 base64url chars. Matches the server's _SESSION_RE // exactly; anything else in this field is dropped whole rather than trimmed. const b = new Uint8Array(16); crypto.getRandomValues(b); return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** The current session id, or "" when there must not be one. Minting is the * moment something is first written to the device, so it happens ONLY here and * ONLY behind hasConsent(). */ function sid() { if (!flagged() || !hasConsent()) return ""; try { const now = Date.now(); let rec = null; try { rec = JSON.parse(sessionStorage.getItem(SID_KEY) || "null"); } catch { rec = null; } if (!rec || !rec.id || now - rec.last > IDLE_MS || now - rec.born > MAX_MS) { rec = { id: mintId(), born: now, last: now, n: 0 }; } rec.last = now; rec.n = Math.min((rec.n || 0) + 1, MAX_SEQ + 1); sessionStorage.setItem(SID_KEY, JSON.stringify(rec)); return rec.n > MAX_SEQ ? "" : rec.id; // past the cap: back to Tier A only } catch { return ""; } // private mode / storage disabled: Tier A only } function seqOf() { try { const rec = JSON.parse(sessionStorage.getItem(SID_KEY) || "null"); return rec && typeof rec.n === "number" ? rec.n : 0; } catch { return 0; } } export function dropSession() { try { sessionStorage.removeItem(SID_KEY); } catch { /* nothing to drop */ } } // Withdrawing consent must take effect immediately, not at the next page load: // drop the live id and discard anything queued but not yet sent. onConsentChange((state) => { if (state !== "granted") { queue = []; dropSession(); } }); // 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, session) { try { const body = JSON.stringify(session ? { events: items, session } : { 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 = []; // Resolved at flush time, not at queue time: if consent was withdrawn between // the two, the id is already gone and the batch goes out anonymously. Strip // the carrier field BEFORE serialising — it must never reach the wire. const session = hasConsent() ? (items[0].__sid || "") : ""; for (const it of items) delete it.__sid; send(items, session); } /** 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; // sid() both reads and advances the counter, so call it exactly once per // event. It returns "" (and writes nothing at all) without consent. const id = sid(); const item = props ? { event, props } : { event }; if (id) { item.seq = seqOf(); item.__sid = id; } queue.push(item); 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 (signalledOptOut || !event) return; send([{ event }]); // anonymous, unbatched, never session-scoped } 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(); });