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:

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:

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


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:

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