Skip to content
HostStack Docs

Site Analytics

Pageviews, visitors, referrers, campaigns, countries and custom events for every site you own — whether it runs on HostStack or somewhere else entirely. One script tag, about 4 KB gzipped, nothing written to the visitor’s device, and so nothing that needs a consent banner. All of your sites appear on one page, so “how did everything do this week” is one glance rather than six logins.

Set it up

  1. Open Analytics in the dashboard and press Add site. Enter the bare hostname — example.com, not a URL.
  2. Paste the snippet into your <head> and deploy. If the domain is already attached to a service here, the site links itself to that service and also shows up on its Analytics tab.
html
<script defer src="https://hoststack.dev/t.js" data-site-key="site_your_key_here"></script>

The site key is public by design. It ships in an HTML attribute that anyone can read, exactly like a Sentry DSN — so it is write-only, scoped to one site, and rotatable from the dashboard. What actually protects the endpoint is the origin check and the hourly quota, both described below.

Custom events

The tracker exposes one global, with track() for events and pageview() for apps that report their own routes. Calls before it loads are simply lost rather than queued — analytics that can throw inside your page is worse than analytics that misses an event.

javascript
// Anything worth counting that isn't a pageview.
psAnalytics.track('signup_started', { plan: 'pro' });
psAnalytics.track('checkout_completed', { plan: 'pro', currency: 'EUR' });

// Metadata is optional: up to 32 keys, 4 KiB total, and it shows up as a
// breakdown when you click the event in the dashboard.

Apps behind a login

A logged-in product puts record ids in its URLs — /customers/123, /orders/ord_9f2a, /patients/9. Reporting those verbatim is not a reporting problem, it is an exfiltration one: identifiers land in an analytics store that had no business holding them, and Top paths shreds into one row per record. Turn the automatic pageview off and tell us the route instead.

html
<script defer src="https://hoststack.dev/t.js"
        data-site-key="site_your_key_here"
        data-auto-pageview="off"></script>
javascript
// Report the route TEMPLATE, not the URL the visitor is on.

// TanStack Router — the deepest match carries the template as its routeId:
router.subscribe('onResolved', () => {
  const matches = router.state.matches;
  psAnalytics.pageview(matches[matches.length - 1].routeId);
  // -> /dashboard/customers/$customerId
});

// React Router — hang the pattern on the route's handle and read it back:
//   { path: 'customers/:customerId', handle: { analytics: '/customers/:customerId' } }
const match = useMatches().at(-1);
psAnalytics.pageview(match?.handle?.analytics);

// Next.js, pages router — router.pathname is already the template:
psAnalytics.pageview(useRouter().pathname);   // -> /customers/[id]
// The app router exposes no template hook: usePathname() gives you the
// resolved URL, ids and all, so pass the string you route with instead.

With data-auto-pageview="off" the tracker never reads the address bar at all — not for pageviews, and not for custom events either. A track() call before your first pageview() reports no path rather than the live one, and the SPA history hooks are not installed. The only paths that reach us are the ones you passed in.

The referrer goes with it. Browsers already trim a cross-origin referrer to a bare origin, so the only one that can still carry a record id is your app linking to itself — and that one is worth nothing as attribution, so with automatic pageviews off it is not sent. Any attribute value other than on, true, 1 or yes counts as off: a typo costs you pageviews, which you will notice, rather than leaving the leak open, which you would not.

You do not have to do any of this to be safe by default. Every path is scrubbed at ingest: a segment that is a bare number, a UUID, a long hex string or a prefix_opaque id with a digit or a capital in it is stored as :id, so /customers/123 becomes /customers/:id whether you asked for it or not. Dated permalinks like /2024/06/12/the-post and snake_case slugs like /docs/site_analytics are left alone. A referrer from one of your own hosts gets the same treatment, querystring dropped and ids collapsed, since that is the visitor’s previous URL by another name; referrers from other sites are stored as they arrive. That is all a guess about your URL scheme though, and it cannot recover the name of the route — which is why reporting it yourself is still better.

What gets stored

No cookies, no IP

Your visitor’s IP is used to look up a country at ingest and is never written to disk. The GeoIP database is self-hosted, so the address is not sent to a third party either.

A visitor is a day

The script stores nothing. Sessions are grouped at ingest by a hash of IP, browser and site, salted with a random value that is generated daily and thrown away — so someone who visits on Monday and Thursday is two visitors, nothing links them, and once the salt is gone no IP can be matched to a past day’s events.

No consent banner needed

ePrivacy art. 5(3) — in Denmark the cookiebekendtgørelse — covers anything stored on a visitor’s device, and it is technology-neutral: a localStorage key needs consent exactly as a cookie does, and analytics is not “strictly necessary”. The tag writes nothing, so the rule never engages. data-storage="local" is the one exception — see below.

Do Not Track is honoured

The tracker exits immediately when DNT is set. An individual visitor can also opt out permanently by setting ps_as_opt_out to 1 in localStorage.

Deleting a site deletes its data

Every event and every rollup row is removed with it, by foreign key, immediately — not on a schedule.

Consent, and the one flag that needs it

ePrivacy art. 5(3), which in Denmark is the cookiebekendtgørelse, governs storing information on a visitor’s terminal equipment or reading it back. It is technology-neutral — localStorage and sessionStorage count exactly as cookies do — and the only exemption is for storage strictly necessary to deliver what the visitor asked for. Analytics is settled as not that. So any tag that stores an id needs prior consent, which means a banner.

This one stores no id, which is why it needs neither. The session id is derived at ingest instead, and it costs a little precision: two people behind one office NAT running the same browser build count as one visitor for the day, and a phone that moves between wi-fi and cellular counts as two.

If you have consent and want that precision back, data-storage="local" puts a day-scoped random id in localStorage. It is off unless you add it, and only the exact value local turns it on — a typo leaves storage off rather than switching it on behind your back. If your site has a consent banner, load the tag only after the visitor agrees; if it does not have one, do not use this flag.

html
<!-- Default: nothing is stored on the device. -->
<script defer src="https://hoststack.dev/t.js"
        data-site-key="site_your_key_here"></script>

<!-- Opt in to a client-side session id. Requires prior consent in the EU:
     load this tag only after the visitor has agreed. -->
<script defer src="https://hoststack.dev/t.js"
        data-site-key="site_your_key_here"
        data-storage="local"></script>

The one thing the tracker reads from localStorage in every mode is ps_as_opt_out — a key it never writes, that exists only because a visitor or a site set it to switch tracking off. Honouring an opt-out someone recorded themselves is the strictly-necessary case the exemption is for.

Ranges, retention and what the numbers mean

Raw events are kept for 35 days — five more than the longest filterable range, so a 30-day view is never missing its first day. Anything older is answered from a daily rollup, and that changes what one number means, so the dashboard says so rather than quietly showing you something else.

Up to 30 days

Unique visitors is a true distinct count over the whole range, and every breakdown can be filtered — click a country, a browser or a path to narrow everything else.

Longer than that

Daily visitors, summed — each day’s uniques added together, so a daily reader counts once per day. Filters and screen sizes are not available this far back.

Across several sites, visitors are always reported per site and never added into one total. The same person on two of your domains is two visitors, and reconciling them would require tracking people across domains — which is the one thing a cookieless tracker exists not to do.

Limits

  • Origin. Events are accepted from your site’s domain and its subdomains. Add more under the site’s settings if you serve the same app from a second hostname.
  • 50,000 events per site per hour. Over that, events are refused and counted — the dashboard shows how many, next to the chart they would otherwise have deflated. Nothing is dropped silently.
  • Rotating a key issues a new one immediately and keeps the old one working for 30 days, so rotating is never an outage.
  • Tag attributes. data-site-key (required), data-auto-pageview="off" to report pageviews yourself (anything but on/true/1/yes means off), data-endpoint to point at your own proxy, and data-debug to turn the tracker’s silent failures into console.warn.

Why is it showing zero?

The ingest endpoint answers 204 to everything, including a site key it has never seen. That is deliberate — a status code that told a real key from a typo would let anyone test keys until they found a live one — but it means five completely different situations produce the same empty chart. So the answer lives somewhere an endpoint open to the world cannot leak it: on the site’s own page, behind your login.

Open the site in Analytics and read the Status panel, or run hoststack analytics check example.com. It says which of these you have:

  • Receiving. Events are landing. An empty chart here means no visitors, not a broken setup.
  • Reaching us, and being refused. Requests arrive and are turned away — a stale key still deployed after a rotation, a hostname that is not the site’s domain or one of its allowed origins, or the hourly quota. The panel names which, and how many, over the last seven days.
  • Nothing has ever reached us. No event and no refusal, so the request is not leaving the browser: the snippet is missing from the deployed HTML, or /t.js is blocked by an ad blocker or a Content-Security-Policy.
  • Automatic pageviews are off and nothing calls pageview(). Identical symptom to the one above, and the tag is behaving exactly as asked. data-debug tells the two apart: its ready line says automatic pageviews off.

For that last one, add data-debug to the script tag. The tracker then logs the endpoint it resolved and every accepted 204 to the console — a confirmation from a real browser, which is the only client whose opinion counts. A curl that lands proves nothing about a page: cross-origin rules are a browser rule and curl is not a browser.

html
<script defer src="https://hoststack.dev/t.js"
        data-site-key="site_your_key_here" data-debug></script>

Proving the domain is yours

Analytics does not require this, and you can ignore this section entirely if analytics is all you want. The site key only labels events your own pages send about themselves, so claiming a hostname you do not own buys nothing but a wrong number in your own dashboard.

It matters for the one thing that makes HostStack act on the hostname: an uptime check on a site we don’t host. That check makes our control plane request your URL every interval, forever, from our IP address — so we will only point it at a name you have shown is yours.

If the domain is already verified on a service in your team, it is proven already and there is nothing to do. Otherwise publish the TXT record shown on the site’s page (or run hoststack analytics verify <domain>) and check it with --check. We read the record from your domain’s own nameservers rather than through a cache, so a record you just published works immediately instead of being hidden for an hour by a negative cache. A DNS lookup that fails never un-verifies a domain that already passed.

Reporting without the script

The tracker is a convenience, not a requirement. Anything that can POST JSON can report — a server-rendered app, a mobile client, a worker. Forwarding events from your own server is a supported setup, not a workaround: it keeps connect-src at 'self' in your CSP and puts the request on a first-party path that content blockers leave alone.

sessionId is optional. Leave it out and ingest derives one the same way it does for the script — except that behind your own forwarder the IP it sees is your server’s, so every visitor would collapse into one session. Send your own instead, and make it something that cannot outlive a day: a salted hash of IP and user-agent with the salt rotated daily and discarded, never a user id, an email or anything stable enough to follow someone across days.

bash
curl -X POST https://hoststack.dev/api/track/site_your_key_here/event \
  -H 'content-type: application/json' \
  -H 'origin: https://example.com' \
  -d '{
    "eventType": "pageview",
    "sessionId": "1c9d5c86-1a9e-4a6f-9b7d-6f6b0f3c2b41",
    "urlPath": "/pricing?utm_source=newsletter",
    "referrer": "https://news.ycombinator.com/"
  }'

From the CLI

bash
hoststack analytics sites
hoststack analytics check example.com         # is it working, and if not, why
hoststack analytics stats example.com --range 30d
hoststack analytics stats --range 12mo        # every site, one table

# Only needed to put an uptime check on a site we don't host:
hoststack analytics verify example.com          # shows the TXT record
hoststack analytics verify example.com --check  # reads DNS, gives the verdict
hoststack uptime set --site 3 --path /

Essential cookies only — for login sessions. No tracking. Details