Skip to content

Analytics ​

Every Events Stream has a built-in Web Analytics dashboard: unique visitors, visits, pageviews, bounce rate and visit duration, where visitors came from, what they read, where they are, what they browse with, which goals they hit, how fast pages load for them, and which JavaScript errors they ran into.

Websites add one script. Apps send the same events over the HTTP API. Open it under Telemetry → Dashboards: each Events Stream is listed under Web Analytics (built in), next to custom dashboards. It can't be edited for now, and its link (like any dashboard's) opens it directly.

Add the script ​

Issue an ingest key for an Events Stream (Manage Streams → Keys & Setup), then add this to every page's <head>:

html
<script defer data-key="lit_XXXXXXXXXXX" src="https://latency.app/js/script.min.js"></script>

That's it. Pageviews start arriving within seconds, including navigation in single-page apps (React, Vue, Svelte, Next.js and the like). The key only writes to its one Stream, so it's safe in page code; give it a key limit so a copied key can only send a little. The readable source is at latency.app/js/script.js for review or self-hosting.

AttributeMeaning
data-keyThe ingest key (required)
data-visitor-keyKeep a visitor id in localStorage under this name (see Visitors)
data-require-consentWith data-visitor-key, store the id only after latency.consent() (see Visitors)
data-manualSend pageviews manually with latency.pageview() (see Pageviews)
data-before-sendThe name of a function that can change or drop each event (see Privacy)
data-hashCount #hash changes as pages
data-no-errorsDon't report JavaScript errors
data-no-vitalsDon't measure page speed
data-no-linksDon't track outbound links and file downloads
data-localAlso track localhost, which is ignored by default
data-debugLog each event, and why one wasn't sent, to the browser console
data-apiSend somewhere other than https://ws.latency.app, such as a first-party proxy domain

To check the setup, add data-debug and open the browser console: every event is logged when sent, or with the reason it wasn't (localhost, automation for test browsers, ignore flag).

With a bundler ​

To import instead of adding a tag, install the package and start the script once in the app:

bash
npm install @latency/analytics
js
import { init, track } from '@latency/analytics';

init({ key: 'lit_XXXXXXXXXXX' });
track('Signup', { plan: 'pro' });

The options match the attributes: api, visitorKey, requireConsent, manual, beforeSend (a function), hash, local, debug, and errors, vitals or links set to false to turn them off. It does nothing when rendered on a server.

What's tracked ​

How
PageviewsEvery page load, single-page app navigation and back/forward navigation. A page the browser prerendered counts once it's shown
SourcesThe referrer, or utm_source / ref / source in the URL. Known sites show by name (Google, Hacker News, X)
Campaignsutm_medium, utm_campaign, utm_content, utm_term
PagesTop pages, plus entry and exit pages of each visit
DevicesBrowser, OS and screen size (mobile, tablet, laptop, desktop)
Time on page and scroll depthTime the page is visible, pausing after a minute without scrolling, clicks, keys or pointer movement. Sent when the visitor leaves or switches tabs. This makes visit duration accurate, not a guess from the gap between pageviews
GoalsCustom events, outbound link clicks (outbound) and file downloads (download, by file type or the download attribute, sent without the query string)
Page speedCore Web Vitals from real visits (below)
JavaScript errorsUncaught errors and unhandled promise rejections, the first 10 distinct ones per page load. Noise nobody can fix is skipped: Script error. from other sites' scripts, ResizeObserver loop warnings and errors from browser extensions

A visit is one visitor's activity with no gap longer than 30 minutes. It counts in the period it started in: visits, bounce rate, visit duration, sources, campaigns and entry pages for a period come from the visits that started in it, while pageviews and visitors count everything that happened in it. A bounce is a visit with a single pageview and no goal.

Crawlers and headless browsers are acknowledged and dropped, so they don't count as visitors or toward the plan.

Pageviews ​

A new path is a new page. A change to the query string alone (a search box, a filter) isn't, and neither is a #hash change without data-hash.

For manual control, add data-manual and call latency.pageview() when a page shows. A custom URL works too, such as a route pattern instead of the ids inside:

js
latency.pageview(); // the current URL
latency.pageview('/users/:id');
latency.pageview({ url: '/users/:id', props: { plan: 'pro' } });

Visitors ​

By default the script is cookieless: it stores nothing in the browser. A visitor is a hash of the site's hostname, the visitor's IP address and browser, and a random salt that changes every week, on Monday at 00:00 UTC. The salt is deleted a day after its week ends, so a hash can't be recomputed once its week is over. Someone who visits several times in one week counts as one visitor. The trade-offs: someone who comes back the next week counts as a new visitor (so over a longer range, visitors add up week by week), a visit that runs across Monday 00:00 UTC counts as two, and people sharing one network and the same browser version count as one.

Each event also keeps the visitor's IP address. It's deleted with the rest of the Stream's data when its retention ends.

Sites that already keep an anonymous id in the browser can name that id with data-visitor-key, and the script uses the id (creating one when missing). Visitors are then counted across days and devices can't collide. Storing an id in the browser may require visitor consent, depending on jurisdiction.

Where consent is required, add data-require-consent too. The script stays cookieless until the consent banner calls latency.consent(), and latency.consent(false) deletes the stored id. An id stored after an earlier consent is used again on later visits.

Privacy ​

Paths, #hash routes, outbound links and error messages can hold data better left uncollected, like a reset token or an email address. Name a function in data-before-send and it gets every event before it's sent. Return the event, changed or not, or null to drop it:

html
<script>
  function redact(event) {
    event.url = event.url.replace(/token=[^&]+/, 'token=redacted');
    return event;
  }
</script>
<script defer data-key="lit_XXXXXXXXXXX" data-before-send="redact" src="https://latency.app/js/script.min.js"></script>

Define the function above the script. If it's missing or throws, the event isn't sent, rather than sent unredacted.

To exclude internal visits, run latency.ignore() in the browser console on the site. Nothing is sent from that browser until latency.ignore(false) runs.

Goals ​

Track anything that matters, with optional properties:

js
latency.track('Signup', { plan: 'pro' });

Or tag an element, no code needed. Clicks send the event with every data-latency-prop-* attribute as a property:

html
<a href="/signup" data-latency-event="Signup" data-latency-prop-plan="pro">Start free trial</a>

To call it before the script has loaded, add this above it. Calls queue up and are sent once it loads:

html
<script>
  window.latency = window.latency || { q: [] };
  ['track', 'pageview', 'consent'].forEach(function (m) {
    latency[m] = latency[m] || function () { latency.q.push([m, arguments]); };
  });
</script>

For a 404 page, send latency.track('404', { path: location.pathname }).

In Analytics, Goals shows visitors, events and conversion rate for each. Click one to filter the whole view by it and break it down by a property. Funnel shows how many visitors did a series of steps in order within one visit (a page, then another page, then a goal), and follows the view's filters. End a page with * to match everything under it, like /docs/*.

Page speed ​

For each page load the script measures the Core Web Vitals and sends them when the page is first hidden, and again if INP or CLS changes after that; the latest report counts. In a single-page app they belong to the page the visit landed on, as in Google's own field data. Analytics shows the 75th percentile, overall and per page, rated the way Google rates them:

GoodPoor
LCP, Largest Contentful Paintup to 2.5sover 4s
INP, Interaction to Next Paintup to 200msover 500ms
CLS, Cumulative Layout Shiftup to 0.1over 0.25
FCP, First Contentful Paintup to 1.8sover 3s
TTFB, Time to First Byteup to 800msover 1.8s

Each report also names the element behind LCP, INP and CLS with a short selector like img.hero, and Page Speed lists the ones most often slower than good.

A browser that can't measure a vital leaves it out rather than reporting a perfect score. Only Chromium browsers measure CLS, so Safari and Firefox visits don't count toward it. Pages loaded in a background tab don't report paint timings, which would otherwise be meaningless, and a prerendered page's timings start when it's shown.

Filters ​

Click any row (a source, page, browser, goal) to filter every number on the page by it, and remove filters from the chips at the top. Explore has the raw events for anything the dashboard doesn't answer (stream:<name> signal:event): they're ordinary events named pageview, engagement, js_error and configured goals. Their pv attribute ties each one to the pageview it happened on.

From an app ​

Apps send the same events to POST /v1/ingest. Use an app:// URL for the screen and send the app's own device id as visitor_id. An event with a visitor_id counts as a visit wherever it comes from:

bash
curl -X POST https://ws.latency.app/v1/ingest \
  -H "Authorization: Bearer lit_XXXXXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"name": "pageview", "url": "app://ios.example.com/cart", "visitor_id": "6f9619ff-8b86-d011-b42d-00c04fc964ff"}'

Send goals the same way. Events without a visitor_id from something other than a browser, like a server or a script, are stored as plain events.

Plans and billing ​

Pageviews, goals and errors count toward the plan's monthly events. The time-on-page pings the script sends on its own don't.

© 2026 Latency Labs LLC