Skip to content

Implement auto collecting events in latest.js #85

Description

@adriaandotcom

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 */constinitClickTracking=({ sendEvent })=>{if(!sendEvent||typeofsendEvent!=="function")return()=>{};constlisteners=[];// Helper, never throwsconstsafeSend=async(name,props)=>{try{awaitsendEvent({ name, props });}catch(_){}};// Classify anchor clicksconstclassifyAnchor=(a)=>{consthref=a.getAttribute("href")||"";if(!href)returnnull;if(href.startsWith("mailto:"))return{name: "click_email",props: { href }};if(href.startsWith("tel:"))return{name: "click_phone",props: { href }};// Treat downloadsif(a.hasAttribute("download")){return{name: "download",props: { href }};}// External vs internaltry{consturl=newURL(href,location.href);constisHttp=url.protocol==="http:"||url.protocol==="https:";if(!isHttp)return{name: "click_link_other",props: { href }};constexternal=url.host!==location.host;returnexternal
? {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 eventsletlastSig="";letlastTs=0;constdedupe=(sig)=>{constnow=Date.now();if(sig===lastSig&&now-lastTs<400)returntrue;lastSig=sig;lastTs=now;returnfalse;};// Pointer and aux clicks, capture so we fire before navigationconstonPointer=async(ev)=>{// Ignore modified clicks that usually do not navigateif(ev.button!==0&&ev.type!=="auxclick")return;constel=ev.target;if(!el)return;// Anchorconsta=el.closest?.("a[href]");if(a){constcls=classifyAnchor(a);if(cls){constsig=`a:${cls.name}:${cls.props?.href||""}`;if(!dedupe(sig))awaitsafeSend(cls.name,cls.props);}return;}// Buttons that look like submitsconstbtn=el.closest?.('button, input[type="submit"], input[type="button"]');if(btn&&btn.form){constsig=`btn:${btn.type}:${btn.name||""}:${btn.value||""}`;if(!dedupe(sig))awaitsafeSend("form_button_click",{id: btn.id||undefined,name: btn.name||undefined,type: btn.type||"button"});return;}// Generic element clicksconstdataAction=el.closest?.("[data-sa-action]");if(dataAction){constname=dataAction.getAttribute("data-sa-action")||"click_custom";constsig=`data:${name}`;if(!dedupe(sig))awaitsafeSend(name,{id: dataAction.id||undefined});}};// Form submitconstonSubmit=async(ev)=>{constform=ev.target;if(!(forminstanceofHTMLFormElement))return;constaction=form.getAttribute("action")||"";constmethod=(form.getAttribute("method")||"GET").toUpperCase();constsig=`submit:${method}:${action}`;if(!dedupe(sig))awaitsafeSend("form_submit",{ method, action });};// Media play and pause, use capture because these do not bubbleconstonMediaPlay=async(ev)=>{constv=ev.target;if(!(vinstanceofHTMLMediaElement))return;constsrc=v.currentSrc||v.src||undefined;constsig=`media:play:${src||""}`;if(!dedupe(sig))awaitsafeSend("video_play",{ src,duration: Math.round(v.duration||0)});};constonMediaPause=async(ev)=>{constv=ev.target;if(!(vinstanceofHTMLMediaElement))return;constsrc=v.currentSrc||v.src||undefined;constsig=`media:pause:${src||""}`;if(!dedupe(sig))awaitsafeSend("video_pause",{
src,t: Math.round(v.currentTime||0)});};// Register listeners, never stop propagation, passive where possibleconstadd=(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 cleanupreturn()=>{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 clickaddEventListener already, meaning they do something on click.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions