feat: add dead click detection to tracking script

This commit is contained in:
Usman Baig
2026-03-12 16:47:53 +01:00
parent 9e6e2a2214
commit 247a0b3460

View File

@@ -547,6 +547,127 @@
}, true); // * Capture phase
}
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
// * or network request occurs within 1 second
// * Opt-out: add data-no-dead to the script tag
if (!script.getAttribute('data-no-dead')) {
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]';
var DEAD_CLICK_DEBOUNCE = 10000;
var DEAD_CLEANUP_INTERVAL = 30000;
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
// * Cleanup stale dead click debounce entries every 30 seconds
setInterval(function() {
var now = Date.now();
for (var key in deadClickDebounce) {
if (!deadClickDebounce.hasOwnProperty(key)) continue;
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
delete deadClickDebounce[key];
}
}
}, DEAD_CLEANUP_INTERVAL);
// * Polyfill check for Element.matches
var matchesFn = (function() {
var ep = Element.prototype;
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
})();
// * Find the nearest interactive element by walking up max 3 levels
function findInteractiveElement(el) {
if (!matchesFn) return null;
var depth = 0;
var current = el;
while (current && depth <= 3) {
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
return current;
}
current = current.parentElement;
depth++;
}
return null;
}
document.addEventListener('click', function(e) {
var target = findInteractiveElement(e.target);
if (!target) return;
var selector = getElementIdentifier(target);
if (!selector) return;
var now = Date.now();
// * Debounce: max one dead_click per element per 10 seconds
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
return;
}
var currentPath = window.location.pathname + window.location.search;
var clickX = String(Math.round(e.clientX));
var clickY = String(Math.round(e.clientY));
var effectDetected = false;
var hrefBefore = location.href;
var mutationObs = null;
var perfObs = null;
var cleanupTimer = null;
function cleanup() {
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
}
function onEffect() {
effectDetected = true;
cleanup();
}
// * Set up MutationObserver to detect DOM changes on the element and its parent
if (typeof MutationObserver !== 'undefined') {
try {
mutationObs = new MutationObserver(function() {
onEffect();
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
if (target.parentElement) {
mutationObs.observe(target.parentElement, mutOpts);
}
} catch (ex) {
mutationObs = null;
}
}
// * Set up PerformanceObserver to detect network requests
if (typeof PerformanceObserver !== 'undefined') {
try {
perfObs = new PerformanceObserver(function() {
onEffect();
});
perfObs.observe({ type: 'resource' });
} catch (ex) {
perfObs = null;
}
}
// * After 1 second, check if any effect was detected
cleanupTimer = setTimeout(function() {
cleanup();
// * Also check if navigation occurred
if (effectDetected || location.href !== hrefBefore) return;
deadClickDebounce[selector] = Date.now();
trackCustomEvent('dead_click', {
selector: selector,
page_path: currentPath,
x: clickX,
y: clickY
});
}, 1000);
}, true); // * Capture phase
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');