Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
213 lines
9.5 KiB
JavaScript
213 lines
9.5 KiB
JavaScript
// Product-event beacon: what people actually do in the UI.
|
|
//
|
|
// Off by default. The server sets `data-tg-events="1"` on <html> 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();
|
|
});
|