Appearance
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.
| Attribute | Meaning |
|---|---|
data-key | The ingest key (required) |
data-visitor-key | Keep a visitor id in localStorage under this name (see Visitors) |
data-require-consent | With data-visitor-key, store the id only after latency.consent() (see Visitors) |
data-manual | Send pageviews manually with latency.pageview() (see Pageviews) |
data-before-send | The name of a function that can change or drop each event (see Privacy) |
data-hash | Count #hash changes as pages |
data-no-errors | Don't report JavaScript errors |
data-no-vitals | Don't measure page speed |
data-no-links | Don't track outbound links and file downloads |
data-local | Also track localhost, which is ignored by default |
data-debug | Log each event, and why one wasn't sent, to the browser console |
data-api | Send 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/analyticsjs
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 | |
|---|---|
| Pageviews | Every page load, single-page app navigation and back/forward navigation. A page the browser prerendered counts once it's shown |
| Sources | The referrer, or utm_source / ref / source in the URL. Known sites show by name (Google, Hacker News, X) |
| Campaigns | utm_medium, utm_campaign, utm_content, utm_term |
| Pages | Top pages, plus entry and exit pages of each visit |
| Devices | Browser, OS and screen size (mobile, tablet, laptop, desktop) |
| Time on page and scroll depth | Time 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 |
| Goals | Custom events, outbound link clicks (outbound) and file downloads (download, by file type or the download attribute, sent without the query string) |
| Page speed | Core Web Vitals from real visits (below) |
| JavaScript errors | Uncaught 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:
| Good | Poor | |
|---|---|---|
| LCP, Largest Contentful Paint | up to 2.5s | over 4s |
| INP, Interaction to Next Paint | up to 200ms | over 500ms |
| CLS, Cumulative Layout Shift | up to 0.1 | over 0.25 |
| FCP, First Contentful Paint | up to 1.8s | over 3s |
| TTFB, Time to First Byte | up to 800ms | over 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.