thermograph/frontend/static/track.js

214 lines
9.5 KiB
JavaScript
Raw Permalink Normal View History

// 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.
//
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
// 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";
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
import { hasConsent, onConsentChange, signalledOptOut } from "./consent.js";
const URL_ = uv("event");
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
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 */ }
}
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
// 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;
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
function send(items, session) {
try {
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
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 = [];
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
// 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;
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
// 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 {
UI events v2: consented session tier, failed-search capture, privacy rewrite 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.
2026-07-23 23:11:06 +00:00
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();
});