> ## 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.

# Analytics de produto

> Uma chamada para registrar um evento, uma fila offline atrás dela, e o funil de sign-in que você ganha sem instrumentar nada.

`track()` registra o que alguém fez no seu produto. Todo o resto desta página
existe porque a parte interessante não é a chamada — é o que acontece com o
evento entre um navegador prestes a ser fechado e um gráfico que alguém lê na
semana seguinte.

## Uma chamada, e ela nunca é uma requisição

```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()` não devolve nada e não espera nada. O evento entra numa fila que mora
no storage do navegador, e a fila sai em lotes — num intervalo de cinco segundos,
no momento em que o navegador volta a ficar **online**, e no **`pagehide`** com
`keepalive`, que é o flush que sobrevive à aba sendo fechada.

Dois campos são cunhados **na chamada**, não no flush:

* **`id`**, um UUIDv7. É o que faz uma reentrega ser contada uma vez só: um lote
  cuja resposta a página morreu antes de ouvir é enviado de novo, e os eventos que
  já tinham chegado não escrevem nada.
* **`occurred_at`**, o relógio do cliente. Uma fila que esperou uma queda passar
  não pode datar tudo no minuto em que a rede voltou.

Um evento sai da fila **somente depois do 202 da API**. Uma falha de rede, um
500, uma tampa de notebook — o lote fica, e o próximo flush o leva. A fila guarda
**500 eventos** e descarta os **mais antigos** quando estoura: comportamento
recente vale mais do que o que uma queda perdeu, e uma fila sem teto seria uma
falha de quota de storage caindo em cima da sessão que divide esse storage com
ela.

Num servidor não há intervalo, nem `online`, nem `pagehide`. Chame `flushEvents()`
você mesmo — é também o dreno explícito de um teste, ou de um "antes de você ir"
seu.

## Nomes

`^[a-z0-9_.:-]{1,64}$`, minúsculas. Um nome fora disso lança
`invalid_event_name` na chamada em vez de ser descartado em algum lugar mais
silencioso — um evento perdido em silêncio é descoberto num dashboard uma semana
tarde demais.

O **prefixo `$` é reservado**, e `track("$auth.signed_in")` lança
`reserved_event_name`. Essa recusa é o que torna os eventos `$auth.*` mais abaixo
confiáveis: nada num navegador consegue escrever no namespace da plataforma.

`properties` é um objeto JSON de no máximo **8 KiB** — dimensões que o explorer
agrupa, não um banco de documentos.

## Propriedades que todo evento carrega

`register()` carimba um conjunto de propriedades em todo evento a partir dali —
o que outras ferramentas chamam de *super properties*.

```ts theme={null}
userkit.register({ organization_id: org.id, plan: org.plan });
userkit.track("checkout.opened");   // sai com organization_id e plan
```

Existe porque a alternativa é lembrar. Um produto multi-tenant quer
`organization_id` em todo evento, e uma propriedade que precisa ser escrita em
trinta lugares é a propriedade que vai faltar exatamente no evento em que alguém
precisava dela.

O carimbo acontece **na chamada**, não no envio: um evento enfileirado antes de um
`register()` descreve um momento em que aquilo ainda não era verdade. As
propriedades do próprio evento vencem numa colisão — quem chamou sabe mais sobre
aquele evento do que o registro sabia.

`unregister("plan")` remove pelo nome. `getRegistered()` devolve uma cópia do que
está sendo carimbado.

<Warning>
  **São limpas junto com a sessão**, pela mesma razão que as feature flags e o time
  ativo são: a próxima sessão pode ser outra pessoa, e um `organization_id` que
  sobrevivesse a um logout não é um buraco no dado — é uma resposta errada dentro
  dele. Registre onde você chama `boot()`, não uma vez no topo da página.
</Warning>

## Page views numa SPA

Um app de página única nunca carrega uma página de novo, então o navegador não tem
nada a relatar e cada produto acaba escrevendo isso contra o próprio router — com
um nome de evento próprio, que é como um produto fica sem conseguir comparar page
views entre dois tenants.

```ts theme={null}
const stop = userkit.trackPageViews();   // page_viewed, agora e a cada navegação
```

Funciona com qualquer router: `pushState` e `replaceState` são embrulhados e
`popstate` é escutado, que é tudo que existe. No `<script>`,
`data-pageviews="auto"` liga a mesma coisa — e vem **desligado** nas duas formas,
porque isso escreve no seu fluxo de eventos e não é uma decisão nossa.

O evento carrega `path`, mais `from` numa navegação, e **nunca a query string**.
Um page view é automático, então o que estiver na URL na hora vai para o analytics:
um token de redefinição de senha, um `?code=` de OAuth (este pacote coloca um lá).
A atribuição também não precisa dela — `utm_*`, o referrer e o `ref` são capturados
uma vez pelo first-touch.

Não há `title`, e a ausência é a metade deliberada: um router troca a URL e renderiza
depois, então `document.title` naquele instante ainda é o da página anterior. Um
título errado é pior que um título ausente. O que você quiser além disso vai em
`register()`, que é carimbado nestes também.

Só conta quando o **path** muda: routers chamam `replaceState` para coisas que não
são navegação — sincronizar um filtro, limpar um parâmetro — e contar isso infla
todo produto que tem uma busca.

## O que o `202` significa

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

**Aceito, nunca durável.** Os eventos ficam em memória de processo e são gravados
em segundos; uma queda entre esses dois momentos perde o que foi aceito e ainda
não gravado. É uma troca explícita, feita para eventos de analytics e para mais
nada — um fato que seu produto não pode perder mora no seu backend, atrás de uma
chave de API, não aqui.

A validação é **por evento**. Uma linha ruim é reportada em `rejected` com seu
índice e um código, e o resto do lote chega assim mesmo: um buffer offline
reenviando cem eventos não pode perder noventa e nove por causa de um timestamp
velho. Os códigos são `invalid_id`, `reserved_name`, `invalid_name`,
`properties_too_large`, `invalid_properties`, `invalid_occurred_at` e
`occurred_at_out_of_range` (`occurred_at` pode estar até **7 dias** no passado —
a janela do próprio buffer offline — e **5 minutos** no futuro, para desvio de
relógio).

Um evento rejeitado é **definitivo**: o SDK o descarta junto com o lote, porque o
servidor já o respondeu e uma nova tentativa só seria recusada de novo.

Uma requisição carrega no máximo **100 eventos** e **256 KiB**, e a porta tem
limite de 600 requisições por minuto por IP, atrás de um teto de 6000 por minuto
por ambiente.

## De quem é o evento

A chave publicável viaja no corpo, exatamente como no
[`boot`](/pt-br/customer-auth/federated), e a lista de origens permitidas da chave
é o portão. O ambiente segue a chave: uma `uk_pk_test_…` escreve eventos de teste.

O `anonymous_id` do dispositivo sempre vai junto, então os eventos de um visitante
são atribuídos antes de qualquer sign-in e se costuram ao contato que um dia
aparece. Uma sessão de contato **nomeia, e nunca barra**:

* com sessão, o lote resolve para aquele contato;
* um token expirado ou revogado em pleno voo rebaixa o lote para anônimo em vez de
  perdê-lo;
* um `contact_id` no corpo é **ignorado**. Um evento que nomeia alguém é uma
  afirmação, e só uma sessão pode fazê-la.

Com o [`@userkit/nextjs`](/pt-br/customer-auth/session-tokens) a mesma chamada
passa pela sua própria origem — a chave fica fora do bundle e o cookie httpOnly
assina o lote.

## De qual time é o evento

Um produto B2B tem gente em mais de um time, e "o que essa pessoa fez" só é uma
pergunta respondível quando se sabe **dentro de qual conta**. Por isso todo evento
carrega também o time.

No navegador, o time é o mesmo que o resto da sessão já usa: o `X-Customer-Id`
que o SDK envia depois de você escolher um time, ou — sem cabeçalho — a associação
mais antiga do contato. Nada de novo para chamar.

Do seu backend, `POST /v1/track` aceita `customer_id` (o id da UserKit) ou
`customer_external_id` (o id no seu sistema, o mesmo que
[espelha o time](/pt-br/api-reference/introduction)) por linha. Os dois juntos são
`ambiguous_customer`; um time que não resolve naquele ambiente é
`unknown_customer` e a linha é recusada em vez de gravada sem time — dizer qual
time e ser ignorado é pior do que não dizer.

**Nenhum time é uma resposta, não um erro.** Um produto sem times nunca preenche
esse campo, e o evento fica sendo da pessoa: no painel ele aparece na leitura de
qualquer time dela, marcado como "Sem time". Eventos gravados antes desta coluna
existir são exatamente esse caso — não há adivinhação retroativa, porque atribuir
hoje um fato antigo é datá-lo a uma relação que pode não existir na época.

## O funil que você não instrumenta

A UserKit publica o que ela mesma viu na mesma pipeline, sob o prefixo reservado.
Um funil que começa no cadastro não precisa de nenhum `track()` seu.

| Evento                    | Quando                                                                                      | Propriedades          |
| ------------------------- | ------------------------------------------------------------------------------------------- | --------------------- |
| `$auth.identified`        | Um visitante deixou de ser anônimo — um primeiro boot identificado. Dispara só na transição | —                     |
| `$auth.signed_in`         | Uma sessão autenticada foi criada para um contato identificado                              | `method`              |
| `$auth.email_verified`    | Um endereço foi provado — magic link ou código por e-mail                                   | —                     |
| `$auth.waitlist_admitted` | Uma fila de recurso deixou pessoas entrarem. Um fato sobre a fila, não sobre uma pessoa     | `admitted`            |
| `$auth.invited`           | Um assento convidado virou uma pessoa num time                                              | `customer_id`, `role` |

Eles são transcritos do [barramento interno](/pt-br/guides/events), não escritos
pelos handlers — é por isso que uma nova porta de entrada não consegue esquecer de
produzir um. Duas consequências que vale conhecer: eles são **duráveis no commit**
e chegam quando o barramento drena (segundos, não milissegundos), e **visitantes
anônimos nunca aparecem** — o widget cria uma sessão por carregamento de página e
nenhuma delas é uma conta.

`$auth.signed_in` carrega `method` e deliberadamente não carrega IP nem user
agent. O explorer responde *como*, nunca *de onde*; de onde veio uma sessão é
pergunta da [trilha de auditoria](/pt-br/guides/events), e ela responde a uma
permissão mais estreita.

## O explorer

**Analytics → Eventos** no painel, atrás da permissão `analytics:read` (owner e
admin por padrão — as mesmas telas mostram receita, então não há um assento de "só
as contagens"; conceda numa role customizada se seu analista não for admin).

Duas leituras moram ali. A lista crua filtra por nome do evento, contato e
período. O gráfico conta eventos por **dia UTC** e nome, e diz qual fonte
respondeu:

* uma janela de **48 horas ou menos** lê os eventos crus, porque é exatamente onde
  o atraso de até uma hora do rollup seria a coisa na tela;
* qualquer janela maior lê o rollup horário, porque reagregar meses de linhas
  cruas a cada refresh é o custo que o rollup existe para pagar uma vez.

As duas aplicam as mesmas regras de agregação, então qual delas respondeu nunca
muda o que um número significa — a resposta diz qual foi porque uma conversa de
suporte sobre um gráfico começa por aí.

Um filtro que vale a pena guardar vira uma **query salva**: um nome e o próprio
formulário de filtros do explorer, guardados por organização e não por ambiente. A
pergunta "onde as pessoas desistem" é a mesma em live e em test; o ambiente é
escolhido na hora de perguntar de novo.

## Funis

Um funil é uma **lista ordenada de nomes de evento** (de 2 a 10) e uma **janela em
dias**.

```json theme={null}
{
  "name": "Cadastro até a primeira compra",
  "steps": [{ "name": "$auth.identified" }, { "name": "$auth.email_verified" }, { "name": "checkout.completed" }],
  "window_days": 14
}
```

Um contato entra quando seu primeiro evento do passo um cai dentro do período
perguntado, e alcança o passo *n* fazendo cada passo em ordem, a sequência inteira
no máximo `window_days` depois de **entrar** — a janela ancora no passo um, então
um passo do meio generoso não estica o funil indefinidamente. O período delimita
quem *entra*, nunca quem termina: quem entrou no último dia ainda tem sua janela
para converter.

Um funil conta **contatos**, então enxerga os eventos que resolveram para um — o
contato de uma sessão anônima incluído, já que um funil sobre visitantes é uma
pergunta legítima. Eventos que carregam apenas um `anonymous_id` estão no explorer
e não num funil.

Os passos podem nomear os eventos `$auth.*` da plataforma e os seus na mesma lista
— essa mistura é o ponto, e é o gráfico que nenhuma ferramenta que só enxerga a
sua instrumentação consegue desenhar.

Os resultados voltam como uma lista densa: todos os passos, inclusive os que
ninguém alcançou, porque um zero é algo que o gráfico precisa desenhar e não um
buraco que ele disfarça. `conversion` é sempre contra o **primeiro** passo — "de
quem entrou" é a pergunta que um funil responde, e a razão de um passo para o
seguinte está a uma divisão de distância no cliente, enquanto o contrário perde
precisão.

## De quem é a meia-noite

Os dias são **UTC** no armazenamento — para as contagens, para os rollups e para
as métricas de receita igualmente. De quem é a meia-noite de um dia se decide uma
vez, no armazenamento, e o fuso da sua organização entra na **exibição**. É por
isso que dois gráficos vindos de duas dessas leituras podem ser sobrepostos e
concordar.

## Por quanto tempo os números ficam

Seu plano define uma retenção para o analytics — **30 dias** no Free, **365** no
Pro — e ela vale em dois lugares, o que convém saber antes de montar um
relatório em cima destas leituras.

**Na leitura.** Toda resposta de analytics traz um objeto `range` dizendo sobre
qual janela ela de fato respondeu:

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

`clamped` é verdadeiro quando o plano empurrou o `from` para frente. Pedir mais
nunca é erro e nunca é `402` — você recebe `200` sobre a janela que o plano
guarda, e o `range` diz isso. Leia o `clamped` em vez de comparar timestamps por
conta própria: só a API sabe qual horizonte ela aplicou. `retention_days` é
`null` quando a leitura enxergou tudo o que está armazenado.

**No armazenamento.** Além desse horizonte os eventos são apagados toda noite,
junto com as contagens diárias e as coortes semanais derivadas deles. Isto **não**
é igual à janela da trilha de auditoria do painel, que só filtra uma leitura:
aqui, mudar para um plano mais longo dá um horizonte maior dali para a frente, e
não o histórico que você tinha antes. Se você precisa guardar analytics além da
retenção do seu plano, exporte — o explorador de eventos é a leitura de onde
puxar.

As métricas de receita são a exceção. Elas são congeladas a partir das suas
faturas, e não derivadas de eventos, então só são recortadas na leitura, nunca
apagadas.

## Onde os números viram dinheiro

[Métricas de receita](/pt-br/guides/revenue-metrics) — MRR, os quatro baldes de
movimento, LTV, NRR — e receita por canal de aquisição são a outra metade deste
módulo, e vêm de assinaturas e faturas, não de eventos.
