Performance metrics moved from dashboard into a new Performance tab. Fixed null handling so "No data" shows instead of misleading zeros. Script no longer sends INP=0 when no interaction occurred.
840 lines
30 KiB
JavaScript
840 lines
30 KiB
JavaScript
/**
|
||
* Pulse - Privacy-First Tracking Script
|
||
* Lightweight, no cookies, GDPR compliant.
|
||
* Default: cross-tab visitor ID (localStorage), optional data-storage-ttl in hours.
|
||
* Optional: data-storage="session" for per-tab (ephemeral) counting.
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// * Respect Do Not Track
|
||
if (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || navigator.msDoNotTrack === '1') {
|
||
return;
|
||
}
|
||
|
||
// * Skip headless browsers and automated tools (Puppeteer, Playwright, Selenium)
|
||
if (navigator.webdriver) {
|
||
return;
|
||
}
|
||
|
||
// * Get domain from script tag
|
||
const script = document.currentScript || document.querySelector('script[data-domain]');
|
||
if (!script || !script.getAttribute('data-domain')) {
|
||
return;
|
||
}
|
||
|
||
const domain = script.getAttribute('data-domain');
|
||
const apiUrl = script.getAttribute('data-api') || 'https://pulse-api.ciphera.net';
|
||
// * Visitor ID storage: "local" (default, cross-tab) or "session" (ephemeral per-tab)
|
||
const storageMode = (script.getAttribute('data-storage') || 'local').toLowerCase() === 'session' ? 'session' : 'local';
|
||
// * When storage is "local", optional TTL in hours; after TTL the ID is regenerated (e.g. 24 = one day)
|
||
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
|
||
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
|
||
|
||
// * Performance Monitoring (Core Web Vitals) State
|
||
let currentEventId = null;
|
||
let metrics = { lcp: 0, cls: 0, inp: 0 };
|
||
let lcpObserved = false;
|
||
let clsObserved = false;
|
||
let performanceInsightsEnabled = false;
|
||
|
||
// * Time-on-page tracking: records when the current pageview started
|
||
var pageStartTime = 0;
|
||
|
||
// * Minimal Web Vitals Observer
|
||
function observeMetrics() {
|
||
try {
|
||
if (typeof PerformanceObserver === 'undefined') return;
|
||
|
||
// * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 2–4s+ after load)
|
||
new PerformanceObserver((entryList) => {
|
||
const entries = entryList.getEntries();
|
||
const lastEntry = entries[entries.length - 1];
|
||
if (lastEntry) {
|
||
metrics.lcp = lastEntry.startTime;
|
||
lcpObserved = true;
|
||
}
|
||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
||
// * CLS (Cumulative Layout Shift) - accumulates when elements shift after load
|
||
new PerformanceObserver((entryList) => {
|
||
for (const entry of entryList.getEntries()) {
|
||
if (!entry.hadRecentInput) {
|
||
metrics.cls += entry.value;
|
||
clsObserved = true;
|
||
}
|
||
}
|
||
}).observe({ type: 'layout-shift', buffered: true });
|
||
|
||
// * INP (Interaction to Next Paint) - Simplified (track max duration)
|
||
new PerformanceObserver((entryList) => {
|
||
const entries = entryList.getEntries();
|
||
for (const entry of entries) {
|
||
// * Track longest interaction
|
||
if (entry.duration > metrics.inp) metrics.inp = entry.duration;
|
||
}
|
||
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
||
|
||
} catch (e) {
|
||
// * Browser doesn't support PerformanceObserver or specific entry types
|
||
}
|
||
}
|
||
|
||
function sendMetrics() {
|
||
if (!currentEventId) return;
|
||
|
||
// * Calculate time-on-page in seconds (always sent, even without performance insights)
|
||
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
|
||
|
||
var payload = { event_id: currentEventId };
|
||
|
||
// * Always include duration if we have a valid measurement
|
||
if (durationSec > 0) payload.duration = durationSec;
|
||
|
||
// * Only include Web Vitals when performance insights are enabled
|
||
if (performanceInsightsEnabled) {
|
||
// * Only include metrics the browser actually reported. Sending 0 would either be
|
||
// * rejected by the backend (LCP/INP must be > 0) or skew averages.
|
||
if (lcpObserved && metrics.lcp > 0) payload.lcp = metrics.lcp;
|
||
if (clsObserved) payload.cls = metrics.cls;
|
||
if (metrics.inp > 0) payload.inp = metrics.inp;
|
||
}
|
||
|
||
// * Skip if nothing to send (no duration and no vitals)
|
||
if (!payload.duration && !performanceInsightsEnabled) return;
|
||
|
||
var data = JSON.stringify(payload);
|
||
|
||
if (navigator.sendBeacon) {
|
||
navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'}));
|
||
} else {
|
||
fetch(apiUrl + '/api/v1/metrics', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: data,
|
||
keepalive: true
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// * Start observing metrics immediately (buffered observers will capture early metrics)
|
||
// * Metrics will only be sent if performance insights are enabled (checked in sendMetrics)
|
||
observeMetrics();
|
||
|
||
// * Send metrics when user leaves or hides the page
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden') {
|
||
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
|
||
setTimeout(sendMetrics, 150);
|
||
}
|
||
});
|
||
|
||
// * Memory cache for session ID (fallback if storage is unavailable)
|
||
let cachedSessionId = null;
|
||
|
||
function generateId() {
|
||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||
}
|
||
|
||
// * Returns session/visitor ID. Default: persistent (localStorage, cross-tab), optional TTL in hours.
|
||
// * With data-storage="session": ephemeral (sessionStorage, per-tab).
|
||
function getSessionId() {
|
||
if (cachedSessionId) {
|
||
return cachedSessionId;
|
||
}
|
||
|
||
const key = 'ciphera_session_id';
|
||
const legacyKey = 'plausible_session_' + (domain ? domain.trim() : '');
|
||
|
||
if (storageMode === 'local') {
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed.id === 'string') {
|
||
const hasValidCreated = typeof parsed.created === 'number';
|
||
const expired = ttlMs > 0 && (!hasValidCreated || (Date.now() - parsed.created > ttlMs));
|
||
if (!expired) {
|
||
cachedSessionId = parsed.id;
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// * Invalid JSON: migrate legacy plain-string ID to { id, created } format
|
||
if (typeof raw === 'string' && raw.trim().length > 0) {
|
||
cachedSessionId = raw.trim();
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify({ id: cachedSessionId, created: Date.now() }));
|
||
} catch (e2) {}
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
}
|
||
cachedSessionId = generateId();
|
||
// * Race fix: re-read before writing; if another tab wrote in the meantime, use that ID instead
|
||
var rawAgain = localStorage.getItem(key);
|
||
if (rawAgain) {
|
||
try {
|
||
var parsedAgain = JSON.parse(rawAgain);
|
||
if (parsedAgain && typeof parsedAgain.id === 'string') {
|
||
var hasValidCreatedAgain = typeof parsedAgain.created === 'number';
|
||
var expiredAgain = ttlMs > 0 && (!hasValidCreatedAgain || (Date.now() - parsedAgain.created > ttlMs));
|
||
if (!expiredAgain) {
|
||
cachedSessionId = parsedAgain.id;
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
} catch (e2) {
|
||
if (typeof rawAgain === 'string' && rawAgain.trim().length > 0) {
|
||
cachedSessionId = rawAgain.trim();
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
}
|
||
// * Final re-read immediately before write to avoid overwriting a fresher ID from another tab
|
||
var rawBeforeWrite = localStorage.getItem(key);
|
||
if (rawBeforeWrite) {
|
||
try {
|
||
var parsedBefore = JSON.parse(rawBeforeWrite);
|
||
if (parsedBefore && typeof parsedBefore.id === 'string') {
|
||
var hasValidCreatedBefore = typeof parsedBefore.created === 'number';
|
||
var expBefore = ttlMs > 0 && (!hasValidCreatedBefore || (Date.now() - parsedBefore.created > ttlMs));
|
||
if (!expBefore) {
|
||
cachedSessionId = parsedBefore.id;
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
} catch (e3) {
|
||
if (typeof rawBeforeWrite === 'string' && rawBeforeWrite.trim().length > 0) {
|
||
cachedSessionId = rawBeforeWrite.trim();
|
||
return cachedSessionId;
|
||
}
|
||
}
|
||
}
|
||
// * Best-effort only: another tab could write between here and setItem; without locks perfect sync is not achievable
|
||
localStorage.setItem(key, JSON.stringify({ id: cachedSessionId, created: Date.now() }));
|
||
} catch (e) {
|
||
cachedSessionId = generateId();
|
||
}
|
||
return cachedSessionId;
|
||
}
|
||
|
||
// * data-storage="session": session storage (ephemeral, per-tab)
|
||
try {
|
||
cachedSessionId = sessionStorage.getItem(key);
|
||
if (!cachedSessionId && legacyKey) {
|
||
cachedSessionId = sessionStorage.getItem(legacyKey);
|
||
if (cachedSessionId) {
|
||
sessionStorage.setItem(key, cachedSessionId);
|
||
sessionStorage.removeItem(legacyKey);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// * Access denied or unavailable - ignore
|
||
}
|
||
|
||
if (!cachedSessionId) {
|
||
cachedSessionId = generateId();
|
||
try {
|
||
sessionStorage.setItem(key, cachedSessionId);
|
||
} catch (e) {
|
||
// * Storage full or unavailable - ignore, will use memory cache
|
||
}
|
||
}
|
||
return cachedSessionId;
|
||
}
|
||
|
||
// * Normalize path: strip trailing slash and all query params except UTM/attribution.
|
||
// * Allowlist approach — only UTM params pass through because the backend extracts
|
||
// * them for attribution before cleaning the stored path. Everything else (cache-busters,
|
||
// * ad click IDs, filter params, etc.) is stripped to prevent path fragmentation.
|
||
var KEEP_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'source', 'ref'];
|
||
function cleanPath() {
|
||
var pathname = window.location.pathname;
|
||
// * Strip trailing slash (but keep root /)
|
||
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
||
pathname = pathname.slice(0, -1);
|
||
}
|
||
// * Only keep allowlisted params, strip everything else
|
||
var search = window.location.search;
|
||
if (search) {
|
||
try {
|
||
var params = new URLSearchParams(search);
|
||
var kept = new URLSearchParams();
|
||
for (var i = 0; i < KEEP_PARAMS.length; i++) {
|
||
if (params.has(KEEP_PARAMS[i])) {
|
||
kept.set(KEEP_PARAMS[i], params.get(KEEP_PARAMS[i]));
|
||
}
|
||
}
|
||
var remaining = kept.toString();
|
||
if (remaining) pathname += '?' + remaining;
|
||
} catch (e) {
|
||
// * URLSearchParams not supported — send path without query
|
||
}
|
||
}
|
||
return pathname;
|
||
}
|
||
|
||
// * SPA referrer: only attribute external referrer to the landing page
|
||
var firstPageviewSent = false;
|
||
|
||
// * Refresh dedup: skip pageview if the same path was tracked within 5 seconds
|
||
// * Prevents inflated pageview counts from F5/refresh while allowing genuine revisits
|
||
var REFRESH_DEDUP_WINDOW = 5000;
|
||
var DEDUP_STORAGE_KEY = 'ciphera_last_pv';
|
||
|
||
function isDuplicatePageview(path) {
|
||
try {
|
||
var raw = sessionStorage.getItem(DEDUP_STORAGE_KEY);
|
||
if (raw) {
|
||
var last = JSON.parse(raw);
|
||
if (last.p === path && Date.now() - last.t < REFRESH_DEDUP_WINDOW) {
|
||
return true;
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
return false;
|
||
}
|
||
|
||
function recordPageview(path) {
|
||
try {
|
||
sessionStorage.setItem(DEDUP_STORAGE_KEY, JSON.stringify({ p: path, t: Date.now() }));
|
||
} catch (e) {}
|
||
}
|
||
|
||
// * Track pageview
|
||
function trackPageview() {
|
||
var routeChangeTime = performance.now();
|
||
var isSpaNav = !!currentEventId;
|
||
|
||
const path = cleanPath();
|
||
|
||
// * Skip if same path was just tracked (refresh dedup)
|
||
if (isDuplicatePageview(path)) {
|
||
return;
|
||
}
|
||
|
||
if (currentEventId) {
|
||
// * SPA nav: visibilitychange may not fire, so send previous page's metrics + duration now
|
||
sendMetrics();
|
||
}
|
||
|
||
metrics = { lcp: 0, cls: 0, inp: 0 };
|
||
lcpObserved = false;
|
||
clsObserved = false;
|
||
currentEventId = null;
|
||
pageStartTime = 0;
|
||
// * Only send external referrer on the first pageview (landing page).
|
||
// * SPA navigations keep document.referrer stale, so clear it after first hit
|
||
// * to avoid inflating traffic source attribution.
|
||
var referrer = '';
|
||
if (!firstPageviewSent) {
|
||
var rawReferrer = document.referrer || '';
|
||
if (rawReferrer) {
|
||
try {
|
||
var refHost = new URL(rawReferrer).hostname.replace(/^www\./, '');
|
||
var siteHost = domain.replace(/^www\./, '');
|
||
if (refHost !== siteHost) referrer = rawReferrer;
|
||
} catch (e) {
|
||
referrer = rawReferrer;
|
||
}
|
||
}
|
||
}
|
||
const screenSize = {
|
||
width: window.innerWidth || window.screen.width,
|
||
height: window.innerHeight || window.screen.height,
|
||
};
|
||
|
||
// * Skip bots with no screen dimensions (0x0)
|
||
if (screenSize.width === 0 && screenSize.height === 0) {
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
domain: domain,
|
||
path: path,
|
||
referrer: referrer,
|
||
screen: screenSize,
|
||
session_id: getSessionId(),
|
||
};
|
||
|
||
// * Send event
|
||
fetch(apiUrl + '/api/v1/events', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(payload),
|
||
keepalive: true,
|
||
}).then(res => res.json())
|
||
.then(data => {
|
||
recordPageview(path);
|
||
firstPageviewSent = true;
|
||
if (data && data.id) {
|
||
currentEventId = data.id;
|
||
pageStartTime = Date.now();
|
||
// * For SPA navigations the browser never emits a new largest-contentful-paint
|
||
// * (LCP is only for full document loads). After the new view has had time to
|
||
// * paint, we record time-from-route-change as an LCP proxy so /products etc.
|
||
// * get a value. If the user navigates away before the delay, we leave LCP unset.
|
||
if (isSpaNav) {
|
||
var thatId = data.id;
|
||
// * Run soon so we set lcpObserved before the user leaves; 500ms was too long
|
||
// * and we often sent metrics (next nav or visibilitychange+150ms) before it ran.
|
||
setTimeout(function() {
|
||
if (!lcpObserved && currentEventId === thatId) {
|
||
metrics.lcp = Math.round(performance.now() - routeChangeTime);
|
||
lcpObserved = true;
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
}).catch(() => {
|
||
// * Silently fail - don't interrupt user experience
|
||
});
|
||
}
|
||
|
||
// * Track initial pageview (skip if page is being speculatively prerendered)
|
||
if (document.prerendering) {
|
||
document.addEventListener('prerenderingchange', function() {
|
||
trackPageview();
|
||
}, { once: true });
|
||
} else {
|
||
trackPageview();
|
||
}
|
||
|
||
// * Track SPA navigation: MutationObserver (DOM updates) and history.pushState/replaceState
|
||
// * (some SPAs change the URL without a DOM mutation we observe)
|
||
let lastUrl = location.href;
|
||
function onUrlChange() {
|
||
var url = location.href;
|
||
if (url !== lastUrl) {
|
||
lastUrl = url;
|
||
trackPageview();
|
||
// * Check for 404 after SPA navigation (deferred so title updates first)
|
||
setTimeout(check404, 100);
|
||
// * Reset scroll depth tracking for the new page
|
||
if (trackScroll) scrollFired = {};
|
||
}
|
||
}
|
||
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
|
||
var _push = history.pushState;
|
||
var _replace = history.replaceState;
|
||
history.pushState = function() { _push.apply(this, arguments); onUrlChange(); };
|
||
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
|
||
|
||
// * Track popstate (browser back/forward)
|
||
window.addEventListener('popstate', function() {
|
||
var url = location.href;
|
||
if (url === lastUrl) return;
|
||
lastUrl = url;
|
||
trackPageview();
|
||
setTimeout(check404, 100);
|
||
if (trackScroll) scrollFired = {};
|
||
});
|
||
|
||
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
|
||
var EVENT_NAME_MAX = 64;
|
||
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||
|
||
function trackCustomEvent(eventName, props) {
|
||
if (typeof eventName !== 'string' || !eventName.trim()) return;
|
||
var name = eventName.trim().toLowerCase();
|
||
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
|
||
if (typeof console !== 'undefined' && console.warn) {
|
||
console.warn('Pulse: event name must contain only letters, numbers, and underscores (max ' + EVENT_NAME_MAX + ' chars).');
|
||
}
|
||
return;
|
||
}
|
||
var path = cleanPath();
|
||
// * Custom events use same referrer logic: only on first pageview, empty after
|
||
var referrer = '';
|
||
if (!firstPageviewSent) {
|
||
var rawRef = document.referrer || '';
|
||
if (rawRef) {
|
||
try {
|
||
var rh = new URL(rawRef).hostname.replace(/^www\./, '');
|
||
var sh = domain.replace(/^www\./, '');
|
||
if (rh !== sh) referrer = rawRef;
|
||
} catch (e) { referrer = rawRef; }
|
||
}
|
||
}
|
||
var screenSize = { width: window.innerWidth || 0, height: window.innerHeight || 0 };
|
||
var payload = {
|
||
domain: domain,
|
||
path: path,
|
||
referrer: referrer,
|
||
screen: screenSize,
|
||
session_id: getSessionId(),
|
||
name: name,
|
||
};
|
||
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
|
||
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
||
var sanitized = {};
|
||
var count = 0;
|
||
for (var key in props) {
|
||
if (!props.hasOwnProperty(key)) continue;
|
||
if (count >= 30) break;
|
||
var k = String(key).substring(0, 200);
|
||
var v = String(props[key]).substring(0, 2000);
|
||
sanitized[k] = v;
|
||
count++;
|
||
}
|
||
if (count > 0) payload.props = sanitized;
|
||
}
|
||
fetch(apiUrl + '/api/v1/events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
keepalive: true,
|
||
}).catch(function() {});
|
||
}
|
||
|
||
// * Expose pulse.track() for custom events (e.g. pulse.track('signup_click'))
|
||
window.pulse = window.pulse || {};
|
||
window.pulse.track = trackCustomEvent;
|
||
|
||
// * Auto-track 404 error pages (on by default)
|
||
// * Detects pages where document.title contains "404" or "not found"
|
||
// * Opt-out: add data-no-404 to the script tag
|
||
var track404 = !script.hasAttribute('data-no-404');
|
||
var sent404ForUrl = '';
|
||
|
||
function check404() {
|
||
if (!track404) return;
|
||
// * Only fire once per URL
|
||
var currentUrl = location.href;
|
||
if (sent404ForUrl === currentUrl) return;
|
||
if (/404|not found/i.test(document.title)) {
|
||
sent404ForUrl = currentUrl;
|
||
trackCustomEvent('404');
|
||
}
|
||
}
|
||
|
||
// * Check on initial load (deferred so SPAs can set title)
|
||
setTimeout(check404, 0);
|
||
|
||
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
|
||
// * Each threshold fires once per pageview; resets on SPA navigation
|
||
// * Opt-out: add data-no-scroll to the script tag
|
||
var trackScroll = !script.hasAttribute('data-no-scroll');
|
||
|
||
if (trackScroll) {
|
||
var scrollThresholds = [25, 50, 75, 100];
|
||
var scrollFired = {};
|
||
var scrollTicking = false;
|
||
|
||
function checkScroll() {
|
||
var docHeight = document.documentElement.scrollHeight;
|
||
var viewHeight = window.innerHeight;
|
||
// * Page fits in viewport — nothing to scroll
|
||
if (docHeight <= viewHeight) return;
|
||
var scrollTop = window.scrollY;
|
||
var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
|
||
|
||
for (var i = 0; i < scrollThresholds.length; i++) {
|
||
var t = scrollThresholds[i];
|
||
if (!scrollFired[t] && scrollPercent >= t) {
|
||
scrollFired[t] = true;
|
||
trackCustomEvent('scroll_' + t);
|
||
}
|
||
}
|
||
scrollTicking = false;
|
||
}
|
||
|
||
window.addEventListener('scroll', function() {
|
||
if (!scrollTicking) {
|
||
scrollTicking = true;
|
||
requestAnimationFrame(checkScroll);
|
||
}
|
||
}, { passive: true });
|
||
}
|
||
|
||
// * Strip HTML tags from a string (used for sanitizing attribute values)
|
||
function stripHtml(str) {
|
||
if (typeof str !== 'string') return '';
|
||
return str.replace(/<[^>]*>/g, '').trim();
|
||
}
|
||
|
||
// * Build a compact element identifier string for frustration tracking
|
||
// * Format: tag#id.class1.class2[href="/path"]
|
||
function getElementIdentifier(el) {
|
||
if (!el || !el.tagName) return '';
|
||
var result = el.tagName.toLowerCase();
|
||
|
||
// * Add #id if present
|
||
if (el.id) {
|
||
result += '#' + stripHtml(el.id);
|
||
}
|
||
|
||
// * Add classes (handle SVG elements where className is SVGAnimatedString)
|
||
var rawClassName = el.className;
|
||
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
|
||
rawClassName = rawClassName.baseVal;
|
||
}
|
||
if (typeof rawClassName === 'string' && rawClassName.trim()) {
|
||
var classes = rawClassName.trim().split(/\s+/);
|
||
var filtered = [];
|
||
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
|
||
var cls = classes[ci];
|
||
if (cls.length > 50) continue;
|
||
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
|
||
filtered.push(cls);
|
||
}
|
||
if (filtered.length > 0) {
|
||
result += '.' + filtered.join('.');
|
||
}
|
||
}
|
||
|
||
// * Add key attributes
|
||
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
|
||
for (var ai = 0; ai < attrs.length; ai++) {
|
||
var attrName = attrs[ai];
|
||
var attrVal = el.getAttribute(attrName);
|
||
if (attrVal !== null && attrVal !== '') {
|
||
var sanitized = stripHtml(attrVal);
|
||
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
|
||
result += '[' + attrName + '="' + sanitized + '"]';
|
||
}
|
||
}
|
||
|
||
// * Truncate to max 200 chars
|
||
if (result.length > 200) {
|
||
result = result.substring(0, 200);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// * Auto-track rage clicks (rapid repeated clicks on the same element)
|
||
// * Fires rage_click when same element is clicked 3+ times within 800ms
|
||
// * Opt-out: add data-no-rage to the script tag
|
||
if (!script.hasAttribute('data-no-rage')) {
|
||
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
|
||
var RAGE_CLICK_THRESHOLD = 3;
|
||
var RAGE_CLICK_WINDOW = 800;
|
||
var RAGE_CLICK_DEBOUNCE = 5000;
|
||
var RAGE_CLEANUP_INTERVAL = 10000;
|
||
|
||
// * Cleanup stale rage click entries every 10 seconds
|
||
setInterval(function() {
|
||
var now = Date.now();
|
||
for (var key in rageClickHistory) {
|
||
if (!rageClickHistory.hasOwnProperty(key)) continue;
|
||
var entry = rageClickHistory[key];
|
||
// * Remove if last click was more than 10 seconds ago
|
||
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
|
||
delete rageClickHistory[key];
|
||
}
|
||
}
|
||
}, RAGE_CLEANUP_INTERVAL);
|
||
|
||
document.addEventListener('click', function(e) {
|
||
var el = e.target;
|
||
if (!el || !el.tagName) return;
|
||
|
||
var selector = getElementIdentifier(el);
|
||
if (!selector) return;
|
||
|
||
var now = Date.now();
|
||
var currentPath = cleanPath();
|
||
|
||
if (!rageClickHistory[selector]) {
|
||
rageClickHistory[selector] = { times: [], lastFired: 0 };
|
||
}
|
||
|
||
var entry = rageClickHistory[selector];
|
||
|
||
// * Add current click timestamp
|
||
entry.times.push(now);
|
||
|
||
// * Remove clicks outside the time window
|
||
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
|
||
entry.times.shift();
|
||
}
|
||
|
||
// * Check if rage click threshold is met
|
||
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
|
||
// * Debounce: max one rage_click per element per 5 seconds
|
||
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
|
||
var clickCount = entry.times.length;
|
||
trackCustomEvent('rage_click', {
|
||
selector: selector,
|
||
click_count: String(clickCount),
|
||
page_path: currentPath,
|
||
x: String(Math.round(e.clientX)),
|
||
y: String(Math.round(e.clientY))
|
||
});
|
||
entry.lastFired = now;
|
||
}
|
||
// * Reset tracker after firing or debounce skip
|
||
entry.times = [];
|
||
}
|
||
}, 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.hasAttribute('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;
|
||
|
||
// * Skip form inputs — clicking to focus/interact is expected, not a dead click
|
||
var tag = target.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') 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 = cleanPath();
|
||
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);
|
||
var parent = target.parentElement;
|
||
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
|
||
mutationObs.observe(parent, { childList: true });
|
||
}
|
||
} 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');
|
||
var trackDownloads = !script.hasAttribute('data-no-downloads');
|
||
|
||
if (trackOutbound || trackDownloads) {
|
||
var FILE_EXT_REGEX = /\.(pdf|zip|gz|tar|xlsx|xls|csv|docx|doc|pptx|ppt|mp4|mp3|wav|avi|mov|exe|dmg|pkg|deb|rpm|iso|7z|rar)($|\?|#)/i;
|
||
|
||
document.addEventListener('click', function(e) {
|
||
var el = e.target;
|
||
// * Walk up from clicked element to find nearest <a> tag
|
||
while (el && el.tagName !== 'A') el = el.parentElement;
|
||
if (!el || !el.href) return;
|
||
|
||
try {
|
||
var url = new URL(el.href, location.href);
|
||
// * Skip non-http links (mailto:, tel:, javascript:, etc.)
|
||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
||
|
||
// * Check file download first (download attribute or known file extension)
|
||
if (trackDownloads && (el.hasAttribute('download') || FILE_EXT_REGEX.test(url.pathname))) {
|
||
trackCustomEvent('file_download', { url: url.href });
|
||
return;
|
||
}
|
||
|
||
// * Check outbound link (different hostname)
|
||
if (trackOutbound && url.hostname && url.hostname !== location.hostname) {
|
||
trackCustomEvent('outbound_link', { url: url.href });
|
||
}
|
||
} catch (err) {
|
||
// * Invalid URL - skip silently
|
||
}
|
||
}, true); // * Capture phase: fires before default navigation
|
||
}
|
||
|
||
})();
|