> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Product analytics

> One call to track an event, an offline queue behind it, and the sign-in funnel you get without instrumenting anything.

`track()` records what somebody did in your product. Everything else on this page
exists because the interesting part is not the call — it is what happens to the
event between a browser that is about to be closed and a chart somebody reads
next week.

## One call, and it is never a request

```ts theme={null}
import { createClient } from "@userkit/js";

const userkit = createClient({ publishableKey: "uk_pk_live_…" });

userkit.track("checkout.opened", { plan: "pro" });
userkit.track("checkout.completed", { plan: "pro", seats: 4 });
```

`track()` returns nothing and awaits nothing. The event goes into a queue that
lives in the browser's storage, and the queue leaves in batches — on a five
second interval, the moment the browser comes back **online**, and on
**`pagehide`** with `keepalive`, which is the flush that survives the tab being
closed.

Two fields are minted **at the call**, not at the flush:

* **`id`**, a UUIDv7. It is what makes a redelivery countable once: a batch the
  page died before hearing the answer to is sent again, and the events that
  already landed write nothing.
* **`occurred_at`**, the client's clock. A queue that waited out an outage must
  not date everything to the minute the network came back.

An event leaves the queue **only after the API's 202**. A network failure, a 500,
a laptop lid — the batch stays and the next flush carries it. The queue holds
**500 events** and drops the **oldest** on overflow: recent behaviour is worth
more than what an outage missed, and a queue that grew without a ceiling would be
a storage quota failure landing on the session it shares that storage with.

On a server there is no interval, no `online` and no `pagehide`. Call
`flushEvents()` yourself — it is also the explicit drain for a test, or for a
"before you go" moment of your own.

## Names

`^[a-z0-9_.:-]{1,64}$`, lowercase. A name outside that throws
`invalid_event_name` at the call rather than being dropped somewhere quieter — an
event silently discarded is discovered in a dashboard a week too late.

The **`$` prefix is reserved**, and `track("$auth.signed_in")` throws
`reserved_event_name`. That refusal is what makes [the events below](#the-funnel-you-do-not-instrument)
worth trusting: nothing in a browser can write into the platform's namespace.

`properties` is a JSON object of at most **8 KiB** — dimensions the explorer
groups by, not a document store.

## Properties every event carries

`register()` stamps a set of properties onto every event from then on — what
other tools call super properties.

```ts theme={null}
userkit.register({ organization_id: org.id, plan: org.plan });
userkit.track("checkout.opened");   // goes out with organization_id and plan
```

It exists because the alternative is remembering. A multi-tenant product wants
`organization_id` on every event, and a property that has to be written at thirty
call sites is the one that will be missing from exactly the event somebody needed
it on.

The stamp happens **at the call**, not at the flush: an event queued before a
`register()` describes a moment when that was not yet true. The event's own
properties win a collision — the call site knows more about that one event than
the registration did.

`unregister("plan")` removes by name. `getRegistered()` returns a copy of what is
being stamped.

<Warning>
  **They are cleared with the session**, for the reason the feature flags and the
  active team are: the next session may be somebody else, and an `organization_id`
  that outlived a sign-out is not a gap in the data — it is a wrong answer inside
  it. Register where you call `boot()`, not once at the top of the page.
</Warning>

## Page views in a SPA

A single-page app never loads a page again, so the browser has nothing to report
and every product ends up writing this against its own router — with an event name
of its own, which is how one product ends up unable to compare page views across
two tenants.

```ts theme={null}
const stop = userkit.trackPageViews();   // page_viewed, now and on every navigation
```

It works with any router: `pushState` and `replaceState` are wrapped and `popstate`
is listened to, which is all there is. On the `<script>`, `data-pageviews="auto"`
turns on the same thing — and both are **off** by default, because this writes to
your event stream and is not our decision to make.

The event carries `path`, plus `from` on a navigation, and **never the query
string**. A page view is automatic, so whatever is in the URL when it fires is in
your analytics: a password-reset token, an OAuth `?code=` (this package puts one
there itself). Attribution does not need it either — `utm_*`, the referrer and
`ref` are captured once by first-touch.

There is no `title`, and its absence is the deliberate half: a router changes the
URL and renders afterwards, so `document.title` at that moment is still the
previous page's. A title that is wrong is worse than a title that is missing.
Anything else you want belongs in `register()`, which is stamped on these too.

It only counts when the **path** changes: routers call `replaceState` for things
that are not navigations — syncing a filter, cleaning a param — and counting those
inflates every product with a search box.

## What `202` means

```json theme={null}
{ "accepted": 3, "rejected": [{ "index": 1, "code": "occurred_at_out_of_range" }] }
```

**Accepted, never durable.** Events are held in process memory and flushed to
storage within seconds; a crash between those two moments loses what was accepted
and not yet flushed. It is an explicit trade, made for analytics events and for
nothing else — a fact your product cannot afford to lose belongs on your backend,
behind an API key, not here.

Validation is **per event**. A bad line is reported in `rejected` with its index
and a code, and the rest of the batch still lands: an offline buffer replaying a
hundred events must not lose ninety-nine to one stale timestamp. The codes are
`invalid_id`, `reserved_name`, `invalid_name`, `properties_too_large`,
`invalid_properties`, `invalid_occurred_at` and `occurred_at_out_of_range`
(`occurred_at` may be up to **7 days** in the past — the offline buffer's own
window — and **5 minutes** in the future, for clock skew).

A rejected event is **final**: the SDK drops it with the batch, because the server
has answered it and a retry would only be refused again.

One request carries at most **100 events** and **256 KiB**, and the door is rate
limited to 600 requests per minute per IP behind a ceiling of 6000 per minute per
environment.

## Who the event belongs to

The publishable key travels in the body, exactly as it does for
[`boot`](/en/customer-auth/federated), and the key's allowed-origins list is the
gate. The environment follows the key: a `uk_pk_test_…` writes test events.

The device's `anonymous_id` always rides along, so a visitor's events are
attributed before anybody has signed in and stitch to the contact who eventually
does. A contact session **names, and never gates**:

* signed in, the batch resolves to that contact;
* a token that has expired or been revoked mid-flight demotes the batch to
  anonymous rather than losing it;
* a `contact_id` in the body is **ignored**. An event naming somebody is an
  assertion, and only a session gets to make it.

With [`@userkit/nextjs`](/en/customer-auth/session-tokens) the same call goes
through your own origin — the key stays out of the bundle and the httpOnly cookie
signs the batch.

## Which team the event belongs to

A B2B product has people in more than one team, and "what did this person do" is
only answerable once you know **inside which account**. So every event carries the
team as well.

In the browser it is the team the session is already using: the `X-Customer-Id`
the SDK sends after you pick one, or — with no header — the contact's oldest
membership. Nothing new to call.

From your backend, `POST /v1/track` takes `customer_id` (ours) or
`customer_external_id` (yours, the same id you mirror the team with) per line.
Both on one line is `ambiguous_customer`; a team that does not resolve in that
environment is `unknown_customer` and the line is refused rather than stored
without one — saying which team and being ignored is worse than not saying.

**No team is an answer, not an error.** A product without teams never fills the
field, and the event is then the person's own: the panel lists it inside every
team of theirs, marked "Sem time". Events written before the column existed are
exactly that case — nothing is guessed retroactively, because attributing an old
fact today would date it to a relationship that may not have existed then.

## The funnel you do not instrument

UserKit publishes what it saw itself into the same pipeline, under the reserved
prefix. A funnel that starts at sign-up needs no `track()` from you at all.

| Event                     | When                                                                                      | Properties            |
| ------------------------- | ----------------------------------------------------------------------------------------- | --------------------- |
| `$auth.identified`        | A visitor stopped being anonymous — a first identified boot. Fires on the transition only | —                     |
| `$auth.signed_in`         | An authenticated session was minted for an identified contact                             | `method`              |
| `$auth.email_verified`    | An address was proven — a magic link, an email code                                       | —                     |
| `$auth.waitlist_admitted` | A feature queue let people in. One fact about the queue, not about one person             | `admitted`            |
| `$auth.invited`           | An invited seat became a person on a team                                                 | `customer_id`, `role` |

They are transcribed from the [internal bus](/en/guides/events), not written by
the handlers, which is why a new way in cannot forget to produce one. Two
consequences worth knowing: they are **durable at commit** and land when the bus
drains (seconds, not milliseconds), and **anonymous visitors never appear** — the
widget mints a session per page load and none of them is an account.

`$auth.signed_in` carries `method` and deliberately not the IP or user agent. The
explorer answers *how*, never *from where*; where a session came from is the
[audit trail's](/en/guides/events) question, and it answers to a narrower
permission.

## The explorer

**Analytics → Events** in the panel, behind the `analytics:read` permission
(owner and admin by default — the same screens carry revenue, so there is no
"just the counts" seat; grant it on a custom role if your analyst is not an
admin).

Two reads sit under it. The raw list filters by event name, contact and range.
The chart counts events per **UTC day** and name, and says which source answered:

* a window of **48 hours or less** reads the raw events, because that is exactly
  where the rollup's up-to-an-hour lag would be the thing on screen;
* anything wider reads the hourly rollup, because re-aggregating months of raw
  rows on every refresh is the cost the rollup exists to pay once.

Both apply the same aggregation rules, so which one answered never changes what a
number means — the response says which one it was because a support conversation
about a chart starts there.

A filter worth keeping becomes a **saved query**: a name and the explorer's own
filter form, stored per organization rather than per environment. The question
"where do people drop off" is the same in live and test; the environment is
picked when you ask it again.

## Funnels

A funnel is an **ordered list of event names** (2 to 10) and a **window in days**.
A contact enters when their first step-one event falls inside the range you are
asking about, and reaches step *n* by doing every step in order, the whole
sequence no later than `window_days` after **entering** — the window anchors at
step one, so a generous middle step cannot stretch a funnel indefinitely. The
range bounds who *enters*, never who finishes: somebody who entered on the last
day still has their window to convert in.

```json theme={null}
{
  "name": "Signup to first purchase",
  "steps": [{ "name": "$auth.identified" }, { "name": "$auth.email_verified" }, { "name": "checkout.completed" }],
  "window_days": 14
}
```

A funnel counts **contacts**, so it sees the events that resolved to one — an
anonymous session's contact included, since a funnel over visitors is a
legitimate question. Events that carry nothing but an `anonymous_id` are in the
explorer and not in a funnel.

Steps may name the platform's `$auth.*` events and your own in the same list —
that mixture is the point, and it is the chart no tool that only sees your
instrumentation can draw.

Results come back as a dense list: every step, including the ones nobody reached,
because a zero is something a chart must draw rather than a hole it papers over.
`conversion` is against the funnel's **first** step — "of those who entered" is
the question a funnel answers, and a step-to-step ratio is one division away in
the client, while the reverse loses precision.

## Whose midnight

Days are **UTC** in storage — for the counts, the rollups and the revenue
metrics alike. Whose midnight a day belongs to is decided once, at the point of
storage, and your organization's timezone is applied at **display**. It is why
two charts drawn from two of these reads can be laid over each other and agree.

## How long the numbers are kept

Your plan sets a retention for analytics — **30 days** on Free, **365** on Pro —
and it applies in two places, which is worth knowing before you build a report
against these reads.

**On the read.** Every analytics response carries a `range` object saying which
window it actually answered over:

```json theme={null}
{
  "range": {
    "from": "2026-08-11T00:00:00Z",
    "to": "2026-09-10T14:22:03Z",
    "retention_days": 30,
    "clamped": true
  }
}
```

`clamped` is true when your plan moved `from` forward. Asking for more is never
an error and never a `402` — you get a `200` over the window your plan keeps,
and the range says so. Read `clamped` rather than comparing timestamps yourself:
only the API knows which horizon it applied. `retention_days` is `null` when the
read looked through everything stored.

**On storage.** Past that horizon the events are deleted nightly, along with the
daily counts and the weekly cohorts derived from them. This is not the same as
the panel's staff audit window, which only filters a read: here, moving to a
longer plan gives you a longer horizon from that day forward, not the history
you had before. If you need analytics kept beyond your plan's retention, export
it — the event explorer is the read to pull from.

Revenue metrics are the exception. They are frozen from your invoices rather
than derived from events, so they are only ever clipped on the read, never
deleted.

## Where the numbers become money

[Revenue metrics](/en/guides/revenue-metrics) — MRR, the four movement buckets,
LTV, NRR — and revenue by acquisition channel are the other half of this module,
and they are derived from subscriptions and invoices rather than from events.
