Browser snippet

One script tag on your site and page views start arriving — no build step, no package to install, no runtime dependencies. Everything it does, it does through the same public events endpoint you could call yourself; the snippet is just the part nobody enjoys writing twice: an anonymous id that survives a reload, batching, and a flush that outlives the tab.

Install

Paste this into your <head> with your project's API key:

index.htmlhtml
<script defer src="https://tidingshq.com/js/v1/tidings.js" data-key="td_your_key"></script>

The file is versioned and cached for an hour. It self-starts from the data-key attribute and exposes window.tidings. The key is an ingestion key: it can only write events, never read them, which is why it is safe in public HTML.

What it sends automatically

A page_view event on load and on every in-app route change, with these properties:

PropertyValue
pathlocation.pathname — the query string is deliberately left out, so ids and tokens in URLs never become event data.
hostlocation.hostname — so one project can carry a marketing site and an app subdomain without merging / on both into one page.
titleThe document title.
referrerdocument.referrer, omitted when there isn't one.
utm_source…and utm_medium, utm_campaign, utm_term, utm_content — each present only when the URL carries it.

Every event carries a distinct_id. Before anyone signs in that is a random anon_<uuid>, stored in localStorage with a 13-month cookie as a fallback, so a returning visitor is recognised as the same person rather than a new one. Read it with tidings.getDistinctId().

Custom events

Everything past page views is one call. The second argument is free JSON — whatever you will want to filter and break down by later:

anywhere in your appjs
tidings.track("signup_completed", { plan: "pro" });

The call returns immediately and never throws — an ad blocker, a blocked cookie jar or an offline network are all swallowed silently. Analytics that can break a checkout is worse than analytics you lose.

Sign-in & sign-out

Call identify once the visitor becomes a known user:

after a successful sign-injs
tidings.identify("user_42");

// optional profile traits
tidings.identify("user_42", { plan: "pro", company: "Harborlight" });

The first time a given anonymous id meets a given user id, the snippet posts one alias and everything that browser did before the sign-in — the ad click, the pricing page, the trial — is rewritten onto the account. From then on events carry the user id directly. It remembers the pair, so a reload or a second call posts nothing; you can safely call it on every page load while a session is active.

Use an id that is stable and not a secret: your internal user id is ideal. It appears in the Tidings UI and in exports, so an email address works but shares more than you may want.

On sign-out, call tidings.reset(). It drops the account id and mints a fresh anonymous one, so the next person at a shared browser doesn't file their events under the account that just left.

Options

Set as attributes on the script tag:

AttributeDefaultNotes
data-keyRequired. Without it the snippet loads and does nothing at all.
data-api-urlhttps://api.tidingshq.comPoint at your own deployment if you self-host.
data-auto-pageviewstrueSet to false to send no page views and drive them yourself.
data-heatmapsfalseSet to "true" to record where people click and how far they scroll — see Heatmaps.
data-heatmaps-textonSet to "off" to capture no element labels at all with clicks. Recommended behind a sign-in, where the text on screen is somebody's data.
data-cookie-domainhost-only".example.com" writes the id cookie on the parent domain, so a visit to your marketing site and the signed-in app on a subdomain are one visitor rather than two. Set it on both surfaces.
data-consentnotrequired"required" holds back all storage and all events until tidings.consent(true); "granted" is what a banner writes into the tag once the visitor has agreed. See Consent.

Every one of these is also an argument to tidings.init() when you would rather start the client from code — load the file without a data-key and call tidings.init({ key, apiUrl, heatmaps, … }) yourself, in camelCase (cookieDomain, heatmapsText). init is idempotent; a second call is ignored.

One option exists only there: beforeSend, a function that sees every event on its way out and can rewrite it or return null to drop it. There is no attribute form because there is no way to write a function in an HTML attribute that we would be willing to run.

tag the source of every eventjs
tidings.init({
  key: "td_your_key",
  heatmaps: true,
  beforeSend: (event) => {
    event.properties.source = "app";
    return event;
  },
});

Heatmaps

Off unless you ask for it. With data-heatmaps="true" the snippet also records where on a page people clicked and how far down it they read, and Insights → Heatmaps draws the result per page and per device band:

index.htmlhtml
<script defer src="https://tidingshq.com/js/v1/tidings.js" data-key="td_your_key" data-heatmaps="true"></script>

It adds two events. A $click on the primary mouse button, at most 50 per page view — a cap, not a sample, so an ordinary page is recorded whole and a click-storm on a canvas app cannot flood your project:

PropertyValue
x, yWhere the click landed. x is a 0–1 fraction of the document width (a page reflows, so only the horizontal share is comparable across viewports); y is the pixel offset from the top of the page, which does mean the same thing on a phone and a monitor.
vw, vh, dhViewport width and height, and the document height. vw is what the device bands — mobile, tablet, desktop — are read off.
tag, selThe element's tag name, and a short selector: the element plus up to three ancestors, each as tag#id or tag.class.class. Class names with digits or over 24 characters are skipped — they are hashed build output and change on the next deploy.
txt, aria, roleThe element's visible text (collapsed, 40 characters), its aria-label, and an explicit role — each present only when there is one, and subject to the masking rules below. This is what turns a hotspot into a row that says Start free trial.
hrefHost and pathname of the nearest enclosing link, without its query string.

And one $scroll per view, sent when the view ends — a route change, or the page being hidden — carrying depth (the deepest fraction of the page reached, 0–1), dwell_ms (how long the view lasted, capped at six hours) and the same vw, vh, dh. Both events carry path and host, so a heatmap is always of one page on one site.

What is never recorded

  • No input values, ever. A click on a password field is ignored outright, and txt is never taken from an input, textarea, select or anything inside [contenteditable]. The click position is still recorded; what was typed is not.
  • data-tidings-mask. Put it on any element and nothing inside it yields a label — the clicks still count, they just arrive anonymous. Use it on order numbers, names, anything a screenshot of that region would embarrass you.
  • Patterns that look personal. A label matching an email address, or containing six or more consecutive digits — an id, a phone number, a card — is dropped rather than truncated.
  • data-heatmaps-text="off" for app surfaces. Behind a sign-in the text on screen is your customer's data, not your copy. This drops txt and aria entirely, leaving positions and selectors, which is all the picture needs.

Both names begin with $, which is how Tidings marks its own instrumentation: a $-prefixed event counts nowhere unless you ask for it by name. It stays out of Schema, Paths, funnels, sessions and top-event lists, so turning capture on never reshapes the numbers you already read — fifty clicks a view would otherwise bury every product event you have. The explorer still streams them, and a filter or a funnel step naming $click still finds them.

Previews: the page under the dots

A cloud of dots is only half an answer, so the heatmap is drawn over the page itself. There are two ways to get one, because a public marketing page and a screen behind a login are different problems.

Server-side screenshots, for public pages. Our own headless browser opens the page and stores a full-page JPEG at three viewport widths — 1440, 834 and 390 CSS pixels, the desktop, tablet and mobile bands. It will only fetch a host your own visitors have already reported page views from in the last 30 days, only over https, only at publicly routable addresses (a private, loopback or cloud-metadata address is refused), and never with cookies or a query string — so what it captures is the page a first-time, signed-out visitor sees.

You do not have to ask for the pages that matter most. As soon as clicks start arriving, the ten busiest pages of the past week are queued for capture on their own, at the desktop and mobile widths, and re-shot about weekly so a redesign does not leave last quarter's layout under this week's dots. Everything else is one press of Capture on the Heatmaps page — and Refresh re-shoots a page you have just changed without waiting for the weekly pass.

Heatmaps open on desktop, and the all-devices view is drawn over the desktop screenshot as well. A click is stored as a fraction of the document width rather than a pixel offset, so it lands in the right place on any width — one layout with every device's clicks on it is worth more than no picture at all, as long as you read phone taps on it knowing they crowd toward the centre of a narrower page. Pick a single device whenever that matters.

The on-site overlay, for everything else. "Open on site" mints a token that is good for ten minutes and one page, and opens https://your-site/page?tidings_heatmap=<token> in a new tab. The snippet already on that page strips the parameter from the URL, loads tidings-overlay.js, and paints the same heatmap over the live page — signed in, in whatever state you reached it. Capture is suspended while the overlay is up, so looking at a heatmap never becomes part of one.

Delivery

Events are queued, not sent one by one. A batch goes out when 20 events are waiting, two seconds after the first one was queued, or immediately when the page is hidden or unloaded — whichever comes first. So a visitor who reads one page and closes the tab still counts.

That last flush uses fetch(…, { keepalive: true }) rather than navigator.sendBeacon: the API key travels in an X-API-Key header, and sendBeacon cannot set headers. A request that fails is retried exactly once and then dropped — the snippet never grows an unbounded queue in someone else's tab.

SPA frameworks

Nothing to wire up. The snippet wraps history.pushState and replaceState and listens for popstate, so React Router, Next.js, Vue Router and everything else that navigates through the History API emit page views on their own. Only a change of path counts: a framework that replaces state on every render won't inflate your numbers.

If you want the page view to fire at a specific moment instead — after a route transition finishes, say, or with the resolved route pattern rather than the raw path — turn the automatic ones off with data-auto-pageviews="false" and send your own:

in your router's navigation hookjs
tidings.track("page_view", { path: route.path });

Privacy

  • Nothing is sent without a key. With no data-key the snippet writes no storage and makes no request — loading it is inert, which is what makes it safe to ship behind a consent gate.
  • One id, in localStorage and a cookie. The tidings_anon_id value is a random UUID on your own origin, readable by nobody else. Where that needs consent first, run the snippet with data-consent="required" — see Consent & privacy.
  • No fingerprinting. No canvas probing, no font or plugin enumeration, no cross-site identifier. If storage is unavailable the id lives in memory for the tab and is then gone.
  • You choose the payload. Beyond path, host, title, referrer and UTM tags — and, only with heatmaps on, click positions and element labels — every property is one you passed in. Don't pass what you don't want stored.