Skip to content

Implement auto collecting events in latest.js #85

@adriaandotcom

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
 */
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.

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