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:
parent
18fe1a8234
commit
cf6a68477f
1 changed files with 164 additions and 18 deletions
182
web/beacon.js
182
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();
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue