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