-
Notifications
You must be signed in to change notification settings - Fork 17
Description
for Simple Analytics i want to detect all kinds of clicks on customers' websites.
can we generate a js event handler for clicks, and based on the element that interacts with the click, store an event? if the click is on a external link: outbout_link, if on a video: click_video (or pause video if we know that), on a mailto: click_email, on a form button: form_submit, etc..
if want the code to be as generic as possible so it collects all kinds of clicks. it should also not interfere with exsiting code from customers, because if we break their website, it's really bad.
GPT-5: Got it. Use one delegated listener, never block default behavior, and classify targets fast. Here is a drop-in initializer with a cleanup, generic and safe.
Click and form tracking initializer
// click-tracking.js (CommonJS)
/**
* @param {{ sendEvent: (evt: {name: string, props?: object}) => Promise<void> }} opts
*/
const initClickTracking = ({ sendEvent }) => {
if (!sendEvent || typeof sendEvent !== "function") return () => {};
const listeners = [];
// Helper, never throws
const safeSend = async (name, props) => {
try { await sendEvent({ name, props }); } catch (_) {}
};
// Classify anchor clicks
const classifyAnchor = (a) => {
const href = a.getAttribute("href") || "";
if (!href) return null;
if (href.startsWith("mailto:")) return { name: "click_email", props: { href } };
if (href.startsWith("tel:")) return { name: "click_phone", props: { href } };
// Treat downloads
if (a.hasAttribute("download")) {
return { name: "download", props: { href } };
}
// External vs internal
try {
const url = new URL(href, location.href);
const isHttp = url.protocol === "http:" || url.protocol === "https:";
if (!isHttp) return { name: "click_link_other", props: { href } };
const external = url.host !== location.host;
return external
? { name: "outbound_link", props: { href: url.href } }
: { name: "click_link", props: { href: url.pathname + url.search + url.hash } };
} catch {
return { name: "click_link_other", props: { href } };
}
};
// De-dupe rapid double events
let lastSig = "";
let lastTs = 0;
const dedupe = (sig) => {
const now = Date.now();
if (sig === lastSig && now - lastTs < 400) return true;
lastSig = sig;
lastTs = now;
return false;
};
// Pointer and aux clicks, capture so we fire before navigation
const onPointer = async (ev) => {
// Ignore modified clicks that usually do not navigate
if (ev.button !== 0 && ev.type !== "auxclick") return;
const el = ev.target;
if (!el) return;
// Anchor
const a = el.closest?.("a[href]");
if (a) {
const cls = classifyAnchor(a);
if (cls) {
const sig = `a:${cls.name}:${cls.props?.href || ""}`;
if (!dedupe(sig)) await safeSend(cls.name, cls.props);
}
return;
}
// Buttons that look like submits
const btn = el.closest?.('button, input[type="submit"], input[type="button"]');
if (btn && btn.form) {
const sig = `btn:${btn.type}:${btn.name || ""}:${btn.value || ""}`;
if (!dedupe(sig)) await safeSend("form_button_click", {
id: btn.id || undefined,
name: btn.name || undefined,
type: btn.type || "button"
});
return;
}
// Generic element clicks
const dataAction = el.closest?.("[data-sa-action]");
if (dataAction) {
const name = dataAction.getAttribute("data-sa-action") || "click_custom";
const sig = `data:${name}`;
if (!dedupe(sig)) await safeSend(name, {
id: dataAction.id || undefined
});
}
};
// Form submit
const onSubmit = async (ev) => {
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
const action = form.getAttribute("action") || "";
const method = (form.getAttribute("method") || "GET").toUpperCase();
const sig = `submit:${method}:${action}`;
if (!dedupe(sig)) await safeSend("form_submit", { method, action });
};
// Media play and pause, use capture because these do not bubble
const onMediaPlay = async (ev) => {
const v = ev.target;
if (!(v instanceof HTMLMediaElement)) return;
const src = v.currentSrc || v.src || undefined;
const sig = `media:play:${src || ""}`;
if (!dedupe(sig)) await safeSend("video_play", { src, duration: Math.round(v.duration || 0) });
};
const onMediaPause = async (ev) => {
const v = ev.target;
if (!(v instanceof HTMLMediaElement)) return;
const src = v.currentSrc || v.src || undefined;
const sig = `media:pause:${src || ""}`;
if (!dedupe(sig)) await safeSend("video_pause", {
src,
t: Math.round(v.currentTime || 0)
});
};
// Register listeners, never stop propagation, passive where possible
const add = (type, fn, opts) => {
document.addEventListener(type, fn, opts);
listeners.push(() => document.removeEventListener(type, fn, opts));
};
add("pointerdown", onPointer, { capture: true, passive: true });
add("auxclick", onPointer, { capture: true, passive: true });
add("submit", onSubmit, { capture: true });
add("play", onMediaPlay, { capture: true });
add("pause", onMediaPause, { capture: true });
// Return cleanup
return () => { listeners.splice(0).forEach((off) => off()); };
};
module.exports = { initClickTracking };How to use
// Example integration
const { initClickTracking } = require("./click-tracking");
/**
* @param {{ name: string, props?: object }} evt
*/
const sendEvent = async ({ name, props }) => {
// Replace with your transport, keep it fire and forget
navigator.sendBeacon?.("/sa-event", JSON.stringify({ n: name, p: props }))
|| fetch("/sa-event", { method: "POST", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ n: name, p: props }) });
};
// Start tracking
const cleanup = initClickTracking({ sendEvent });
// Call cleanup() if you need to remove listeners later
Another customer
I would like to get an event on every button push, menu link, internal link and external link.
This is what one customer wants.
Notes
• Single delegated handler, so minimal risk of interfering with site code. No preventDefault, no stopPropagation.
• Uses capture for submit and media so it works across frameworks.
• Handles anchors, mailto, tel, downloads, external vs internal, form submits, media play and pause, plus a tiny custom hook via data-sa-action.
• Uses dedupe to avoid double fires from fast clicks.
• Keep payloads small to avoid collecting sensitive data. Avoid sending element text. Use IDs, tag types, hrefs, and form action only.
Adriaan notes
I would keep it more simple, not dedupe, just a bunch of addEventListener(). Check .button and .btn clicks too, or check if elements have an existing click addEventListener already, meaning they do something on click.