From cf6a68477ff5ad68d2c4f767112e05b88ff07158 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Mar 2026 17:14:45 +0300 Subject: [PATCH] web: improve Javascript beacon for Plausible 'compatibility' Some features from Plausible that I think I'll miss. Here are some of the noteworthy ones: - Configuration via data attributes (api, domain, hash-mode, etc.) - Automatic localhost detection and path exclusions - Hash-based routing for SPA support - Custom referrer support for events - Duplicate pageview detection Signed-off-by: NotAShelf Change-Id: I6d19378404d0cb9920f12e0cdd163a8e6a6a6964 --- web/beacon.js | 182 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 164 insertions(+), 18 deletions(-) diff --git a/web/beacon.js b/web/beacon.js index 42b9f1e..da89602 100644 --- a/web/beacon.js +++ b/web/beacon.js @@ -2,23 +2,65 @@ (function () { "use strict"; - var endpoint = "/api/event"; + // Configuration from script tag data attributes + var scriptEl = document.currentScript; + var config = { + endpoint: scriptEl.getAttribute("data-api") || "/api/event", + domain: scriptEl.getAttribute("data-domain") || window.location.hostname, + hashMode: scriptEl.hasAttribute("data-hash-mode"), + outboundLinks: scriptEl.hasAttribute("data-outbound-links"), + fileDownloads: scriptEl.hasAttribute("data-file-downloads"), + exclude: scriptEl.getAttribute("data-exclude") || "", + manual: scriptEl.hasAttribute("data-manual"), + }; + var tracked = false; + var lastPage = null; + + // Parse exclusions (comma-separated paths) + var exclusions = config.exclude + ? config.exclude.split(",").map(function (s) { + return s.trim(); + }) + : []; + + // Check if page should be tracked + function shouldTrack() { + // Skip localhost unless explicitly allowed + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + return false; + } + + // Check exclusions + var path = window.location.pathname; + for (var i = 0; i < exclusions.length; i++) { + if (path.indexOf(exclusions[i]) === 0) { + return false; + } + } + + return true; + } // Send analytics payload to server function sendBeacon(payload) { + if (!shouldTrack()) return; + var data = JSON.stringify(payload); // Try navigator.sendBeacon first (best for page unload) if (navigator.sendBeacon) { var blob = new Blob([data], { type: "application/json" }); - navigator.sendBeacon(endpoint, blob); + navigator.sendBeacon(config.endpoint, blob); return; } // Fallback to fetch for browsers without sendBeacon if (window.fetch) { - fetch(endpoint, { + fetch(config.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: data, @@ -32,7 +74,7 @@ // Final fallback to XMLHttpRequest try { var xhr = new XMLHttpRequest(); - xhr.open("POST", endpoint, true); + xhr.open("POST", config.endpoint, true); xhr.setRequestHeader("Content-Type", "application/json"); xhr.send(data); } catch (e) { @@ -41,44 +83,148 @@ } // Build payload - function buildPayload() { + function buildPayload(opts) { + opts = opts || {}; return { - d: window.location.hostname, - p: window.location.pathname, - r: document.referrer || "", + d: config.domain, + p: opts.path || window.location.pathname + window.location.search, + r: opts.referrer !== undefined ? opts.referrer : document.referrer || "", w: window.screen.width || 0, }; } // Track a pageview - function trackPageview() { - if (tracked) return; // Only track once per page load + function trackPageview(opts) { + opts = opts || {}; + + // Get current page (with hash if hash-mode is enabled) + var currentPage = window.location.pathname + window.location.search; + if (config.hashMode) { + currentPage += window.location.hash; + } + + // Avoid duplicate pageviews + if (lastPage === currentPage && !opts.force) { + return; + } + + lastPage = currentPage; tracked = true; - sendBeacon(buildPayload()); + + var payload = buildPayload({ + path: currentPage, + referrer: opts.referrer, + }); + + sendBeacon(payload); } // Track a custom event - function trackEvent(eventName) { + function trackEvent(eventName, opts) { if (!eventName || typeof eventName !== "string") { console.warn("Watchdog: event name must be a non-empty string"); return; } - var payload = buildPayload(); + opts = opts || {}; + var payload = buildPayload(opts); payload.e = eventName; sendBeacon(payload); } + // Track outbound link clicks + function trackOutboundLink(event) { + var link = event.target.closest("a"); + if (!link) return; + + var url = link.getAttribute("href"); + if (!url || url.indexOf("://") === -1) return; + + // Check if external + var linkHostname = link.hostname; + if (linkHostname === window.location.hostname) return; + + // Track as custom event + trackEvent("Outbound Link: Click", { + path: window.location.pathname, + referrer: url, + }); + } + + // Track file downloads + function trackFileDownload(event) { + var link = event.target.closest("a"); + if (!link) return; + + var href = link.getAttribute("href"); + if (!href) return; + + // Common file extensions. + // FIXME: this needs to be more robust in the future + var extensions = [ + ".pdf", + ".zip", + ".tar", + ".gz", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ]; + + var isDownload = false; + for (var i = 0; i < extensions.length; i++) { + if (href.toLowerCase().indexOf(extensions[i]) !== -1) { + isDownload = true; + break; + } + } + + if (!isDownload) return; + + // Track as custom event + trackEvent("File Download", { + path: href, + }); + } + + // Setup automatic tracking features + function setupAutomaticTracking() { + // Hash mode, track when hash changes (for SPAs) + if (config.hashMode) { + window.addEventListener("hashchange", function () { + trackPageview(); + }); + } + + // Outbound link tracking + if (config.outboundLinks) { + document.addEventListener("click", trackOutboundLink); + } + + // File download tracking + if (config.fileDownloads) { + document.addEventListener("click", trackFileDownload); + } + } + // Expose public API window.watchdog = { track: trackEvent, trackPageview: trackPageview, }; - // Auto-track pageview on load - if (document.readyState === "complete") { - trackPageview(); - } else { - window.addEventListener("load", trackPageview); + // Auto-track pageview on load (unless manual mode) + if (!config.manual) { + if (document.readyState === "complete") { + trackPageview(); + } else { + window.addEventListener("load", trackPageview); + } } + + // Setup automatic tracking features + setupAutomaticTracking(); })();