Skip to content

Site analytics

Pageviews, visitors, referrers, campaigns, countries and custom events for every site you own — on HostStack or anywhere else. One script tag, nothing stored on the visitor's device, and so no consent banner.

How it works

You add a site by hostname and paste a script tag into its pages. The tag, about 4 KB gzipped, reports each pageview to HostStack; the dashboard's Analytics page shows every site you track on one page, so "how did everything do this week" is one glance rather than six logins.

  • Any site. It does not have to run on HostStack. If it does, it also appears on that service's Analytics tab.
  • What you see: pageviews, visitors, bounce rate, visit duration, visitors online now, and top paths, referrers, campaigns (UTM), countries, devices, browsers, operating systems, languages, screen sizes and custom events.
  • What it never does: set a cookie, write to the visitor's device, or store their IP address.

Set it up

  1. Add the site

    Open Analytics in the dashboard and click Add site. Enter the bare hostname — example.com, not a URL. If that domain is attached to one of your services, the site is linked to that service automatically.

  2. Paste the snippet into your pages

    Put the tag from the site's Install card in your <head>, then deploy:

    index.html
    <script defer src="https://hoststack.dev/t.js" data-site-key="site_..."></script>
  3. Check that it's receiving

    Load a page, then read the Status panel on the site's page (it refreshes every 30 seconds), or run hoststack analytics check example.com. It should say Receiving events. If it doesn't, see Why is it showing zero?

The site key (site_…) is public by design: it sits in an HTML attribute anyone can read. It can only add events to one site and read nothing. What protects the endpoint is the origin check and the hourly quota, described under Limits.

Custom events

The tag exposes one global, window.psAnalytics, with track(name, metadata) for events and pageview(path) for apps that report their own routes.

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

// Optional-chained on purpose: the tag loads with defer, and psAnalytics
// does not exist until it has run. A call before that is lost, not queued.
  • Event names are up to 64 characters.
  • Metadata is optional: up to 32 keys of up to 64 characters each, and 4 KB of JSON in total. An event over those limits is refused.
  • Click an event under Top events in the dashboard to see its metadata broken down.

The tag also sends a session_end event on its own, once per page, when the tab is hidden or closed. It carries only the time spent on the page and is what visit duration is computed from.

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 has no business holding them, and Top paths shreds into one row per record. Turn the automatic pageview off and report the route instead.

index.html
<script defer src="https://hoststack.dev/t.js"
        data-site-key="site_..."
        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;
  window.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);
window.psAnalytics?.pageview(match?.handle?.analytics);

// Next.js, pages router — router.pathname is already the template:
window.psAnalytics?.pageview(useRouter().pathname); // -> /customers/[id]
// The app router has 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 tag never reads the address bar at all:

  • No automatic pageviews, and no history hooks for single-page navigation.
  • A track() call before your first pageview() reports no path rather than the live one.
  • pageview() with no path does nothing (with data-debug it warns in the console).
  • A referrer from your own site is not sent: it would be the previous URL, ID and all, and it is worth nothing as attribution. Browsers already trim referrers from other sites to a bare origin.

Only an absent attribute, or on, true, 1 or yes, leaves automatic pageviews on. Anything else — including a typo — turns them off: a typo costs you pageviews, which you will notice, rather than leaving the leak open, which you would not.

Paths are scrubbed anyway

You don't have to do any of this to be safe by default. At ingest, the query string is dropped from every path (UTM parameters are kept, in their own columns), and a path segment that looks like an ID is stored as :id:

  • a bare number, a UUID, or a hex string of 16 characters or more;
  • a prefix_body ID whose body is 8 or more characters with a digit in it or mixed case — ord_9f2a41c8, cus_ABCdef12.

So /customers/123 is stored as /customers/:id. Dated permalinks like /2024/06/12/the-post and snake_case slugs like /docs/site_analytics are left alone. A referrer from one of the site's own hosts gets the same treatment; referrers from other sites are stored as they arrive. That is still a guess about your URL scheme, and it cannot recover the name of the route — which is why reporting the route yourself is better.

What happens
Cookies and device storageNone by default. The only thing the tag reads from localStorage is an opt-out flag (below), and it never writes to it unless you add data-storage="local".
IP addressUsed at ingest to look up a country and derive the session ID, then discarded — there is no IP column. The country lookup uses a GeoIP database on HostStack’s own servers, so the address is not sent to a third party.
VisitorsA session ID is a hash of IP address, browser user agent and site, salted with a random value generated each UTC day. The salt lives only in Redis and expires after three days. Someone who visits on Monday and Thursday is two visitors, nothing links them, and once the salt is gone no IP address can be matched to a past day’s events.
Across sitesThe site is part of the hash, so one person on two of your sites is two unrelated IDs. Visitors are always reported per site and never added into one total.
Do Not TrackHonoured: the tag does nothing when the browser sends DNT. A visitor (or your own site) can also opt out permanently by setting ps_as_opt_out to 1 in localStorage.
BotsObvious crawlers, headless browsers, Lighthouse, uptime monitors and command-line clients (curl, wget, python-requests) are not counted, and neither are prefetches.
Deleting a siteDeletes every event and every daily rollup row for it immediately, not on a schedule.

ePrivacy art. 5(3) — in Denmark, the cookiebekendtgørelse — governs storing information on a visitor's device or reading it back. It is technology-neutral: localStorage counts exactly as a cookie does, 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 tag stores nothing, so the rule never engages. (Reading ps_as_opt_out, a key the tag never writes, honours an opt-out the visitor recorded themselves: the strictly-necessary case the exemption exists for.)

The price is a little precision. Two people behind one office NAT on the same browser version count as one visitor for the day, and a phone that moves between wi-fi and mobile data counts as two.

data-storage="local" puts a random session ID in localStorage (under ps_as_sid), rotated once a UTC day. It is off unless you add it, and only the value local turns it on — a typo leaves storage off rather than switching it on behind your back.

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

<!-- Opt in to a client-side session id. Needs 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_..."
        data-storage="local"></script>

Ranges, retention and what the numbers mean

Ranges are the last 24 hours, 7 days, 30 days, 90 days and 12 months. Raw events are kept for 35 days by default — five more than the 30-day range, so its first day is never missing. A range longer than a site's retention is answered from a daily rollup instead, and that changes what one number means, so the dashboard says so:

From raw eventsFrom the daily rollup
Default ranges24 hours, 7 days, 30 days90 days, 12 months
Visitor metricUnique visitors — a true distinct count over the whole rangeDaily visitors, summed — each day's uniques added together, so a daily reader counts once per day
FiltersClick a country, browser, path, campaign and so on to narrow everything elseNot available
BreakdownsEverything, including screen sizes and UTM term and contentNo screen sizes, UTM term or content; the top 200 values per breakdown per day, the rest grouped as (other)

Raise a site's retention (up to 365 days) with hoststack analytics set example.com --retention 365 to keep longer ranges exact and filterable. When you look at several sites at once, the rollup is used as soon as the range is longer than the shortest retention among them.

Script tag attributes

data-site-key
stringrequired

The site's key, site_….

data-auto-pageview
on | off

off to report pageviews yourself — see Apps behind a login. Anything other than on, true, 1 or yes means off.

Default: on

data-storage
local

local keeps a day-scoped session ID in localStorage. Needs consent.

Default: none

data-endpoint
URL

Send events somewhere other than https://hoststack.dev — for example your own proxy of /api/track/ on your domain, which content blockers leave alone. The tag appends /api/track/<site-key>/event.

data-debug
boolean

Log to the console what the tag is doing: the endpoint it resolved, the mode it is in, each accepted event, and every failure it would otherwise swallow.

Limits

  • Origin. Events are accepted from the site's domain and its subdomains. To serve the same site from another hostname, add it (up to 20) with hoststack analytics set example.com --allowed-origins a.com,b.com — the flag replaces the list, and "" clears it. Requests with no Origin header, such as server-side forwarding, are not origin-checked.
  • 50,000 events per site per hour. Over that, events are refused and counted, and the dashboard shows how many next to the chart they would otherwise have deflated. Nothing is dropped silently.
  • 60 requests a second per IP address at the ingest endpoint.
  • Rotating a key (Rotate key on the Install card, or hoststack analytics rotate) issues a new one immediately and keeps the old one working for 30 days, so rotating is never an outage.

Why is it showing zero?

The ingest endpoint answers 204 to every well-formed request, including one with a 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 very different problems produce the same empty chart. So the answer lives behind your login instead: the Status panel on the site's page, or hoststack analytics check example.com, says which of these you have, from the last seven days of refusals.

Receiving events.

An event arrived in the last hour. If it says Receiving events, and refusing some., it names what is being refused.

Reaching us, and being refused.

Requests arrive and are turned away — a stale key still deployed after a rotation's 30 days, a hostname that is not the site's domain or an allowed origin, or the hourly quota. The panel names which, and how many.

No events recently.

Events have arrived before, but none in the last hour. The setup works: either there is no traffic, or the snippet has since been removed.

Nothing has ever reached us.

No event and no refusal, so requests are 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. Or automatic pageviews are off and nothing calls pageview() — the tag is then doing exactly what it was told.

To tell those last cases apart, add data-debug to the tag. Its first console line says which mode it is in (for example automatic pageviews off), and each event it sends is logged as accepted (HTTP 204) — a confirmation from a real browser, the only client whose opinion counts. A curl that lands proves nothing about a page: cross-origin rules are enforced by browsers, and curl is not one (and is filtered out as a bot anyway).

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

Report without the script

The tag 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: it keeps connect-src at 'self' in your CSP and puts the request on a first-party path that content blockers leave alone.

bash
curl -X POST https://hoststack.dev/api/track/site_.../event \
  -H 'content-type: application/json' \
  -H "user-agent: $VISITOR_USER_AGENT" \
  -d '{
    "eventType": "pageview",
    "sessionId": "1c9d5c861a9e4a6f9b7d6f6b0f3c2b41",
    "urlPath": "/pricing?utm_source=newsletter",
    "referrer": "https://news.ycombinator.com/",
    "language": "en-GB"
  }'
eventType
string

Up to 64 characters.

Default: pageview

urlPath
string

Path and query string, up to 2,048 characters. UTM parameters are read from the query string.

referrer
string

Up to 2,048 characters.

sessionId
string

8–64 characters. See below.

screenWidth, screenHeight
number

0–20,000.

language
string

A language tag such as en-GB, up to 32 characters.

metadata
object

Up to 32 keys and 4 KB, as for custom events.

A forwarder sees the world from your server, which changes three things:

  • Send a sessionId. Left out, ingest derives one from the IP address it sees — your server's — so every visitor collapses into one session. Make it something that cannot outlive a day: a hash of the visitor's IP address and user agent with a salt you rotate daily and discard. Never a user ID, an email, or anything stable enough to follow someone across days.
  • Pass the visitor's User-Agent. Browser, OS and device come from it, and a request with your HTTP client's own user agent (curl/…, python-requests/…) is dropped as a bot.
  • Countries will be your server's. The country is looked up from the connecting IP address, and there is no field to pass the visitor's.

Prove the domain is yours

Analytics does not need this — skip this section if analytics is all you want. The site key only labels events your own pages send about themselves, so claiming a hostname you don't own buys nothing but a wrong number in your own dashboard.

It matters for one thing: an uptime check on a site HostStack doesn't host. That check makes HostStack request your URL on a schedule, from its own IP address, so it is only pointed at a hostname you have shown is yours.

  • If the domain is already verified on a service in your team, it is proven and there is nothing to do.
  • Otherwise publish the TXT record shown on the site's page — at _hoststack-verify.example.com, with a value starting hoststack-verify= — and check it.
bash
hoststack analytics verify example.com          # shows the TXT record to publish
hoststack analytics verify example.com --check  # reads DNS and gives the verdict
hoststack uptime set --site 3 --path /          # 3 = the site's ID from: hoststack analytics sites

The record is read from your domain's own nameservers rather than through a cache, so one you have just published works immediately instead of being hidden by a negative cache. Once verified, a domain stays verified: a DNS lookup that fails, or a record you later remove, does not undo it.

From the CLI, SDK or MCP

hoststack analytics add example.com
hoststack analytics snippet example.com       # the script tag to paste
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

# Allow a second hostname, and keep raw events for 90 days
hoststack analytics set example.com --allowed-origins example.net --retention 90

hoststack analytics rotate example.com        # new key; the old one works 30 more days
hoststack analytics rm example.com            # deletes the site and all its data

Next steps

Was this page helpful?

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