Product analytics
Who uses your product, where they are, what they do in it, and whether they come back β joined to the errors, traces and queries Sentrinel already collects.
That join is the reason this exists rather than a separate analytics tool. A product analytics tool can tell you conversion fell. It cannot tell you the query underneath got slow. Sentrinel has both in one place:
327 people hit this error β watch three of their replays β the trace shows
one slow query β the Databases tab already named the missing index
Setup
Product events ride the same SDK and the same ingest key as everything else.
import { initSentrinelBrowser } from '@sentrinel/plugin/browser';
const sentrinel = initSentrinelBrowser({
endpoint: '/api/sentrinel', // the tunnel; no key in the page
release: '2026.8.8',
});
// Pageviews are automatic, including SPA route changes.
sentrinel.track('checkout_started', { plan: 'pro', seats: 3 });
sentrinel.setUser('user_123'); // at login
setUser is the only call that matters for identity, and it can come late β
see below.
Flutter and Dart
The same three calls, from a mobile app:
Sentrinel.screen('Cart');
Sentrinel.track('checkout_started', properties: {'plan': 'pro', 'seats': 3});
Sentrinel.identify('user_123'); // at login
Mobile and web events land in the same funnels β a signup that starts on the website and finishes in the app is one funnel, not two.
Two differences from the browser worth knowing:
- Screens are not automatic. There is no URL to watch, so call
screen()from your route observer.track()andidentify()behave identically. - The anonymous id is written to disk, beside the crash spool, so it survives app restarts. That persistence is what makes mobile retention mean anything: without it every launch would read as a new person, and a funnel spanning two sessions would never complete. On Flutter web there is no filesystem, so the id lasts one page load β use the browser SDK there.
Optional: city-level geography
Country resolution is built in. City and coordinates need a MaxMind-format database, which you supply:
SENTRINEL_GEOIP_DB=/etc/sentrinel/dbip-city-lite.mmdb
DB-IP Lite is CC BY 4.0 and drops straight in; GeoLite2 works too. The reader ships with Sentrinel; the data does not, because it is not ours to redistribute.
On accuracy, stated plainly: country-level IP geolocation is reliable.
City-level is roughly 50β75%, and materially worse on mobile carriers β a Dar es
Salaam user on a phone routinely resolves to Nairobi, or to nothing finer than
TZ. Country is a fact. City is a hint, and the UI marks which it had.
Identity, and why it is the whole game
Every event carries two ids:
anonymous_id |
the device. Always present, from first visit. |
user_id |
the person. Absent until setUser() β so the top of every funnel has none. |
Counting either alone gives a wrong answer that looks right:
- By device: one person on a phone and a laptop is two users.
- By person: every logged-out visitor vanishes, deleting the top of every funnel.
So Sentrinel derives one user_key β the person once known, the device before
that β and resolves it at read time through the anonymousβperson links recorded
when setUser() fires. A visitor who browses anonymously and then signs up is
one person, and a funnel they completed reads 100% rather than 50%.
This was measured, not assumed. The same five events reported 2 users and 50% conversion before the resolution was wired in, and 1 user and 100% after.
No fingerprinting. No canvas hashing, no IP+user-agent composite. Clear your storage and you are a new visitor β which is the answer you asked for by clearing it.
Privacy
- Geo is resolved from the connecting address, not from the payload. The SDKs may send a country and it is accepted as a last resort, but every location records which of the three sources answered. A browser can claim to be anywhere, and a map that mixes a resolved dot with an asserted one silently is a map that lies about half its dots.
- IPs are truncated before storage β
/24for IPv4,/48for IPv6. Once geo is resolved the remaining bits buy nothing a monitoring product needs, and an IP is personal data in most jurisdictions. - Private addresses are never located. Resolving
10.0.0.4to a country would be inventing data. - Paths are normalised, so
/orders/8f3aβ¦and/orders/9b21β¦are one page. That is mostly about grouping, but it doubles as redaction: an id in a path is often a customer number, and an email in one is an email.
Reading the pages
Overview β people, active now (last 5 minutes), new vs returning, top pages and screens, and the live map.
The map is coloured by error rate, not by traffic. A map of where your users are is decoration you look at once; a map of where it is breaking is something you open during an incident. Dot area scales with users, because area is what the eye reads as magnitude.
Features β what share of your users touched each feature, and whether that share is still climbing. A feature whose 7-day users are most of its all-time users is still being discovered; one where they are a small fraction has settled.
Funnels β ordered steps, with the drop between each. A person counts only if they hit the steps in order, which is the part hand-rolled funnels get wrong β counting each step independently reports conversions that never happened.
Retention β cohorts by signup week. A cohort too young to have reached a day
shows β, not 0%. The difference between "nobody came back" and "we cannot
know yet" is the entire value of the grid.
Speed β web vitals at p75, sliced by country, device or release. Only ever by segment: a global p95 averages away the users most likely to leave, and on a product with people on fibre in one region and 3G in another it describes nobody.
Alerts β see below.
People β one person's whole timeline, reaching back across the login boundary to what the device did before it had a name. Each event links to the trace it caused, which is the way into the request, its spans and the query underneath.
Alerts on things that are not errors
Four signals no error monitor will report, because none of them are errors. Every one compares a metric against its own recent history β a fixed threshold is wrong for every product except the one it was tuned on, and wrong for that one a month later.
| Alert | Fires when | Why it matters |
|---|---|---|
| Traffic cliff | active users fall far below their own median | often the first sign of an outage β a request that never arrives cannot fail, so error rate stays flat |
| Funnel drop | a step converts far worse than its recent normal | a deploy broke signup and nothing threw |
| Adoption stall | a young feature stops climbing | the question a launch is judged on |
| Traffic spike | far above normal | a launch, a crawler, or a retry storm about to become an incident |
Two deliberate choices keep this quiet enough to stay trusted:
- Median and MAD, not mean and standard deviation. One spike drags the mean up and inflates the deviation, so the very incident you want to catch raises the bar for catching it.
- A floor on the baseline. Three users to one is a 67% drop and means nothing.
The current hour is excluded from the traffic check, because a bucket judged half-complete looks exactly like a cliff β every hour, forever.
Issues ranked by people, not by noise
The Issues list can sort by users affected. This is the ranking that matters and the one every error tracker gets wrong by default:
sorted by occurrences: TimeoutError 40 occurrences, 1 person
sorted by users affected: TypeError 25 occurrences, 25 people β this one
Occurrence count measures how loud a bug is. A retry loop is loud and harms one person; a checkout crash is quiet and harms everyone who tried to pay.
Filling it with data
KEY=snt_live_β¦ APP=my-app ENV=prod DAYS=30 USERS=400 INCIDENT=on \
bun run examples/product-simulator.ts
Generates weighted geography, a two-humped daily rhythm in local time, funnel drop-off, adoption S-curves, decaying retention, and optionally a scripted regional incident. Seeded, so a run that exposes a bug can be repeated exactly.
LIVE=on keeps emitting afterwards, which is how to see the map pulse.
This is not decoration. Uniform random data makes every panel look identical and every panel look wrong β retention that does not decay and a funnel that does not narrow would each hide a real bug in the code that renders them. Two were caught this way that no unit test would have found: "new vs returning" that read zero at every range, and a backdate clamp that silently compressed thirty days of history into one afternoon.
Volume and cost
Product events routinely outnumber requests several times over, so they count against the same plan quota rather than being free. Events are capped at 500 per batch, properties at 50 keys and 4KB, and event names at 200 characters.
| Table | Kept |
|---|---|
events |
180 days |
user_sessions |
180 days |
users, user_daily |
400 days |
identity_links |
400 days |