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 <raf@notashelf.dev>
Change-Id: I6d19378404d0cb9920f12e0cdd163a8e6a6a6964
This commit is contained in:
raf 2026-03-01 17:14:45 +03:00
commit cf6a68477f
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -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();
})();