Extends the existing /api/v2/event beacon into a small, typed, durable event schema so the interactive views can answer product questions the request log cannot: whether a visitor ever gets a location, whether search finds anything, which controls and views earn their maintenance, and which dead ends people actually hit. Seven events with three enum dimension slots, stored as hourly aggregates in a TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view. No row per interaction and no column an identifier could go in: no cookie, no session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is a per-minute rate-limit key and nothing else. Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist (unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling raised to 120/min (30 was sized for four coarse events and would have silently truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204. Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on <html> that makes the client send anything, and is unset everywhere. See UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and the privacy decisions that must be settled before the flag is turned on.
129 lines
5.2 KiB
JavaScript
129 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();
|
|
});
|