thermograph/frontend/static/digest.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

51 lines
1.9 KiB
JavaScript

// Digest signup form. Loaded on every page (base.html.j2).
//
// It degrades: the form is a real <form method="post"> that works with this
// file blocked.
//
// The product-event beacon used to live here; it now lives in track.js, which
// also owns the [data-event] click delegation this file used to do. `track` is
// re-exported for the pages that import it from here.
export { track, trackLegacy } from "./track.js";
import { trackLegacy } from "./track.js";
// --- digest form -------------------------------------------------------------
for (const form of document.querySelectorAll("form[data-digest]")) {
const status = form.querySelector(".digest-status");
const button = form.querySelector("button[type=submit]");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = form.querySelector("input[name=email]")?.value?.trim();
if (!email) return;
button.disabled = true;
try {
const res = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, place: form.dataset.place || null }),
});
const data = await res.json().catch(() => ({}));
if (status) {
status.textContent = data.message || "Something went wrong. Try again.";
status.hidden = false;
}
if (res.ok) {
// Collapse the inputs on success so the confirmation is the only thing
// left to read; the form is done either way.
form.querySelector(".digest-row")?.setAttribute("hidden", "");
form.querySelector(".digest-note")?.setAttribute("hidden", "");
trackLegacy("home.digest_signup");
}
} catch {
if (status) {
status.textContent = "Couldn't reach the server. Try again.";
status.hidden = false;
}
} finally {
button.disabled = false;
}
});
}