thermograph/frontend/static/track.js

130 lines
5.2 KiB
JavaScript
Raw 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.
//
// 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();
});