Add website analytics
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s

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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:55 -06:00
1 parent 4ea4fe5289
commit b4fd31a182
13 files changed
+843 -22

No files matched your search

+94 -1
View File
@@ -227,6 +227,28 @@
.feed .empty { margin:0; }
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
.msg .tag { margin-right:var(--s1); }
/* website analytics */
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:var(--s3); margin-bottom:var(--s4); }
.stat { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); }
.stat .n { font-size:var(--text-xl); font-weight:600; font-variant-numeric:tabular-nums; }
.stat .l { font-size:var(--text-sm); color:var(--text-muted); }
.chart { display:flex; align-items:flex-end; gap:2px; height:180px; margin-top:var(--s3); }
.chart .col { position:relative; flex:1; min-width:0; height:100%; display:flex; align-items:flex-end; }
.chart .col:hover { background:var(--surface-2); }
.chart .v, .chart .u { position:absolute; left:0; right:0; bottom:0; border-radius:2px 2px 0 0; }
.chart .v { background:color-mix(in srgb, var(--accent) 35%, var(--surface)); }
.chart .u { background:var(--accent); }
.axis2 { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
.legend { display:flex; gap:var(--s4); font-size:var(--text-sm); color:var(--text-muted); flex-wrap:wrap; }
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:var(--s1); vertical-align:baseline; }
.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:var(--s4); margin-top:var(--s4); }
.cols .card { margin:0; }
.top { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
.top td { padding:var(--s1) var(--s2); }
.top td:first-child { background:linear-gradient(to right, var(--accent-soft) var(--w), transparent var(--w)); overflow-wrap:anywhere; }
.top td.num { text-align:right; white-space:nowrap; width:1%; }
.top th { text-align:right; font-weight:500; color:var(--text-muted); padding:var(--s1) var(--s2); }
.top th:first-child { text-align:left; }
</style>
</head>
<body>
@@ -360,6 +382,7 @@ const ICONS = {
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
websites:'<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
@@ -420,6 +443,7 @@ const PAGES = {
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
users: ['People', 'Everyone who can sign in, and how to reach them.'],
@@ -427,7 +451,7 @@ const PAGES = {
};
const NAV = [
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
['Observe', [['monitors', 'Monitors', 'monitors'], ['topics', 'Topics', 'topics']]],
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
];
function route() {
@@ -754,6 +778,75 @@ function passwordDialog(u) {
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
}
// ---------- websites (analytics) ----------
const RANGES = [['24h', 'Last 24 hours'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ['90d', 'Last 90 days']];
const num = n => Number(n || 0).toLocaleString();
function regionName(code) { try { return new Intl.DisplayNames(undefined, {type:'region'}).of(code) || code; } catch (e) { return code; } }
function languageName(code) { try { return new Intl.DisplayNames(undefined, {type:'language'}).of(code) || code; } catch (e) { return code; } }
views.websites = async () => {
if (S.arg) return websiteDetail(S.arg);
const admin = S.me.role === 'admin';
S.headerAction = admin ? ['Add website', () => websiteDialog()] : null;
const [sites, sum] = await Promise.all([api('GET', V1 + '/websites'), api('GET', V1 + '/website-summary')]);
if (!sites.length) return emptyState('websites', 'No websites yet', 'Add a website, then paste one line of code into its pages. Visitors and page views show up here. No cookies are set and no personal data is kept.',
admin ? h('button', {class:'btn', onclick:() => websiteDialog()}, 'Add website') : null);
return table(['Website', 'Visitors, last 24 hours', 'Page views, last 24 hours', ''], sites.map(w => { const s = sum[w.id] || {};
return h('tr', {}, h('td', {}, h('a', {href:'#websites/' + w.id}, w.name), w.domain ? h('div', {class:'small dim'}, w.domain) : null),
h('td', {class:'num'}, num(s.visitors)), h('td', {class:'num'}, num(s.views)),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code'),
admin ? h('button', {class:'ghost', onclick:() => websiteDialog(w)}, 'Edit') : null,
admin ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${w.name}?`, body:'The website and all of its visits and events are removed. This cannot be undone.', confirm:'Delete website'}, async () => { await api('DELETE', `${V1}/websites/${w.id}`); toast('Website deleted.'); refresh(); })}, 'Delete') : null)));
}));
};
function websiteDialog(w) {
const e = !!w, f = { name: h('input', {value:w ? w.name : ''}), domain: h('input', {value:w ? w.domain : '', placeholder:'example.com'}) };
dialogForm(e ? 'Edit ' + w.name : 'Add a website', null, [field('Name', f.name), field('Domain', f.domain, 'Optional. When set, visits from other domains are ignored, which stops others counting against your site.')], e ? 'Save changes' : 'Add website',
async () => { const saved = await api(e ? 'PUT' : 'POST', e ? `${V1}/websites/${w.id}` : V1 + '/websites', {...(w || {}), name:f.name.value, domain:f.domain.value}); toast(e ? 'Website saved.' : 'Website added.'); await refresh(); if (!e) trackingDialog(saved); });
}
function trackingDialog(w) {
const tag = `<script defer src="${origin()}/script.js" data-website="${w.id}"><\/script>`;
const done = modal('Tracking code for ' + w.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Paste this inside the head of every page you want to count.'), cmdBox(tag),
h('p', {class:'dim', style:'margin:0'}, 'Single-page sites are counted as people move between pages. To count something else, such as a sign-up, call this from your code:'),
cmdBox('sandbar.track("signup", { plan: "pro" })'),
h('p', {class:'small dim', style:'margin:0'}, 'The script sets no cookies, keeps no IP address, and does nothing when the visitor has Do Not Track on. Behind a reverse proxy, set SANDBAR_TRUST_PROXY=1 so visitors are told apart.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done'))));
}
async function websiteDetail(id) {
S.noHeader = true;
const range = S.siteRange || '7d';
const [sites, st] = await Promise.all([api('GET', V1 + '/websites'), api('GET', `${V1}/websites/${id}/stats?range=${range}`)]);
setTimeout(() => { S.noHeader = false; });
const w = sites.find(x => x.id === id);
if (!w) return emptyState('websites', 'That website no longer exists', 'It may have been deleted.', h('a', {class:'btn', href:'#websites'}, 'Back to websites'));
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Time range'}, RANGES.map(([k, l]) => h('button', {class:range === k ? 'on' : '', 'aria-pressed':range === k, onclick:() => { S.siteRange = k; refresh(); }}, l)));
const tile = (n, l) => h('div', {class:'stat'}, h('div', {class:'n'}, n), h('div', {class:'l'}, l));
const stats = h('div', {class:'stats'}, tile(num(st.visitors), 'Visitors'), tile(num(st.views), 'Page views'), tile(num(st.visits), 'Visits'),
tile(st.bounceRate >= 0 ? Math.round(st.bounceRate) + '%' : 'No data', 'Bounce rate'), tile(num(st.online), 'Online now'));
const max = Math.max(1, ...st.series.map(p => p.views));
const fmt = p => new Date(p.t * 1000).toLocaleString(undefined, st.bucket === 'hour' ? {month:'short', day:'numeric', hour:'numeric'} : {month:'short', day:'numeric', year:'numeric'});
const chart = h('div', {class:'chart', role:'img', 'aria-label':`${num(st.views)} page views from ${num(st.visitors)} visitors in the ${RANGES.find(r => r[0] === range)[1].toLowerCase()}`},
st.series.map(p => h('div', {class:'col', title:`${fmt(p)}: ${num(p.visitors)} visitors, ${num(p.views)} page views`},
h('div', {class:'v', style:`height:${p.views ? Math.max(2, p.views * 100 / max) : 0}%`}), h('div', {class:'u', style:`height:${p.visitors ? Math.max(2, p.visitors * 100 / max) : 0}%`}))));
const list = (title, rows, label, nameOf) => {
const top = Math.max(1, ...rows.map(r => r.views));
return h('div', {class:'card'}, h('h2', {}, title), rows.length ? h('table', {class:'top'}, h('thead', {}, h('tr', {}, h('th', {scope:'col'}, label), h('th', {scope:'col'}, 'Visitors'), h('th', {scope:'col'}, 'Views'))),
h('tbody', {}, rows.map(r => h('tr', {}, h('td', {style:`--w:${Math.round(r.views * 100 / top)}%`}, nameOf ? nameOf(r.name) : r.name), h('td', {class:'num'}, num(r.visitors)), h('td', {class:'num'}, num(r.views))))))
: h('p', {class:'dim', style:'margin:0'}, 'Nothing yet.'));
};
const nothing = !st.views && !st.events.length;
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#websites', class:'small'}, 'Back to websites'), h('h1', {}, w.name), h('p', {}, w.domain || 'Any domain')),
h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code')),
tabs, h('div', {style:'height:16px'}), stats,
nothing ? emptyState('websites', 'No visits in this period', 'Paste the tracking code into your pages, then open one. The first visit appears within a few seconds.', h('button', {class:'btn', onclick:() => trackingDialog(w)}, 'Show tracking code')) : h('div', {},
card('Visitors and page views', h('div', {class:'legend'}, h('span', {}, h('i', {style:'background:var(--accent)'}), 'Visitors'), h('span', {}, h('i', {style:'background:color-mix(in srgb, var(--accent) 35%, var(--surface))'}), 'Page views')), chart,
h('div', {class:'axis2', 'aria-hidden':'true'}, h('span', {}, fmt(st.series[0])), h('span', {}, 'Now'))),
h('div', {class:'cols'}, list('Pages', st.pages, 'Path'), list('Referrers', st.referrers, 'Source'), list('Browsers', st.browsers, 'Browser'), list('Operating systems', st.systems, 'System'),
list('Devices', st.devices, 'Device'), list('Countries', st.countries, 'Country', n => n.length === 2 ? regionName(n) : n), list('Languages', st.languages, 'Language', n => n.length <= 3 ? languageName(n) : n),
list('Events', st.events, 'Event'))));
}
// ---------- account ----------
// Administrators see which release is running and can install a newer one. The server restarts
// into the new binary, so the page waits for it to come back.
+66
View File
@@ -0,0 +1,66 @@
/* 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);
})();