thermograph/frontend/static/track.js
Emi Griffith 0ddc457d8c UI product-event instrumentation (design + flagged-off prototype)
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.
2026-07-23 15:49:55 -07:00

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();
});