Files
VeillaxandClaude Sonnet 5.5 b4fd31a182
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a
page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages,
referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored
IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:55 -06:00

67 lines
2.5 KiB
JavaScript

/* Sandbar website analytics. Add to a page:
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
It sets no cookies and stores nothing in the browser. It sends the page path, the referrer, the
language and the browser's own user agent, and it honors Do Not Track.
Options on the tag: data-hash (count #hash changes as pages), data-auto="false" (send nothing
until you call sandbar.view() yourself). Custom events: sandbar.track("signup", {plan: "pro"}). */
(function () {
var tag = document.currentScript;
if (!tag) return;
var site = tag.getAttribute('data-website');
if (!site) return;
var base = tag.src.replace(/\/script\.js(\?.*)?$/, '');
var dnt = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.msDoNotTrack === '1';
var withHash = tag.hasAttribute('data-hash');
var last = '';
var first = true;
function send(body) {
if (dnt) return;
body.website = site;
body.hostname = location.hostname;
body.language = navigator.language || '';
var json = JSON.stringify(body);
// text/plain keeps this a "simple" cross-origin request, so no preflight is needed
try {
if (navigator.sendBeacon && navigator.sendBeacon(base + '/api/send', new Blob([json], { type: 'text/plain' }))) return;
} catch (e) { /* fall through to fetch */ }
try {
fetch(base + '/api/send', { method: 'POST', body: json, headers: { 'Content-Type': 'text/plain' }, keepalive: true, mode: 'cors', credentials: 'omit' });
} catch (e) { /* analytics must never break the page */ }
}
function view() {
var path = location.pathname + (withHash ? location.hash : '');
if (path === last) return;
last = path;
send({ path: path, referrer: first ? document.referrer : '' });
first = false;
}
function hook(name) {
var orig = history[name];
if (!orig) return;
history[name] = function () {
var r = orig.apply(this, arguments);
setTimeout(view, 0);
return r;
};
}
hook('pushState');
hook('replaceState');
window.addEventListener('popstate', function () { setTimeout(view, 0); });
if (withHash) window.addEventListener('hashchange', function () { setTimeout(view, 0); });
window.sandbar = {
view: view,
track: function (name, data) {
if (!name) return;
send({ path: location.pathname, event: String(name).slice(0, 80), data: data });
}
};
if (tag.getAttribute('data-auto') === 'false') return;
if (document.readyState === 'complete') view();
else window.addEventListener('load', view);
})();