# Track events

Source: https://docs.mirafive.io/guides/track-events

> Record pageviews, custom events, revenue, clicks and site searches, and know the naming rules and limits every SDK applies.

An event is a named thing that happened, with optional properties. Browser SDKs record pageviews for you; you add custom events with `track()` from the browser or the server. This page covers the rules every SDK shares. For the request itself, see [Send events](https://docs.mirafive.io/ingest-api/send-events).

## Pageviews

Pageviews are automatic. The [script tag](https://docs.mirafive.io/sdks/script-tag) records them by default, and the [browser SDK](https://docs.mirafive.io/sdks/browser) records them with the `pageviews()` plugin. Both send `$pageview` for the landing page and for every same-document navigation, for any router: through the Navigation API, or else through `pushState`, `replaceState` and `popstate`.

- The same path and query twice in a row is one pageview.
- The title is read one macrotask after a navigation, so the router has set it.
- After the first pageview, the referrer is the previous page of your site.
- With hash routing (`#/pricing`), turn on hash mode: `data-hash` on the script tag, `pageviews({ hash: true })` in the browser SDK. Hash changes then count as pageviews and the fragment stays in the URL.

To send pageviews yourself, turn the automatic ones off (`data-manual`, or `pageviews({ initial: false })` for the landing page only) and call `pageview()`:

**Script tag**

```html
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…" data-manual></script>
<script>
  mirafive('pageview', { url: '/checkout/step-2', title: 'Checkout: shipping' })
</script>
```

**Browser**

```ts
mira.pageview({ url: '/checkout/step-2', title: 'Checkout: shipping' })
```

`pageview()` without an argument uses the current URL, `document.title` and the referrer.

## Custom events

Call `track(name, properties)` where the thing happens:

**Script tag**

```html
<script>
  mirafive('track', 'signup', { plan: 'pro' })
</script>
```

Calls made before `mira.js` has loaded wait in the queue from the first line of the [install snippet](https://docs.mirafive.io/sdks/script-tag#set-up).

**Browser**

```ts
mira.track('signup', { plan: 'pro' })
```

**React**

```tsx
import { useMira } from '@mirafive/sdk-react'

export function SignupButton() {
  const mira = useMira()

  return <button onClick={() => mira.track('signup', { plan: 'pro' })}>Sign up</button>
}
```

**Node.js**

```ts
import { Mira } from '@mirafive/sdk-server'

const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })

mira.track('signup', { userId: user.id, properties: { plan: 'pro' } })
```

**PHP**

```php
$mira->track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);
```

**Laravel**

```php
use MiraFive\Laravel\Facades\Mira;

Mira::track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);
```

Browser events carry the current page, with its URL cleaned (see [Page URLs](#page-urls)). Server events carry a page only when you pass `page: { url, title, referrer }`.

## Event names

A name has 1 to 128 characters and no leading or trailing whitespace. Names are labels such as `signup` or `order completed`: never put an email address, a name or other personal data in them. Pick one style and keep it, since `signup` and `Signup` are two events.

A leading `$` is reserved for these names:

| Name | Sent by | Carries |
| --- | --- | --- |
| `$pageview` | browser SDKs | `page`. `$boot: 1` when the consent answer was known when the page first drew |
| `$autocapture` | browser SDKs | the clicked, submitted or changed element (see [Autocapture](#autocapture)) |
| `$identify` | `identify()`, full mode only | `userId` and the person's traits as properties |
| `$search` | site search, full mode only | `query` |
| `$exposure` | flag reads, full mode only | `$experiment`, `$variant`, see [Experiments](https://docs.mirafive.io/guides/experiments) |
| `$install_check` | setup checks | nothing; never stored or billed, see [Verify and debug](https://docs.mirafive.io/guides/verify-and-debug#install-check) |

The server refuses any other name that starts with `$`, and drops `$identify`, `$search` and `$exposure` from a consentless batch. In the browser, `track()` refuses every `$` name; the SDK sends the reserved ones itself.

## Properties

Properties are a JSON object. The server refuses a batch when one event breaks a limit, so every SDK checks them first:

| Limit | Value |
| --- | --- |
| Size | 32 KB, encoded as UTF-8 JSON |
| Values | 64 leaf values. A list or an empty object counts as one |
| Nesting | 5 levels |
| Key length | 128 characters |

What happens to an event over a limit:

| SDK | Behaviour |
| --- | --- |
| Script tag, browser SDK | The event is dropped alone; a development warning names it (`[mirafive] event dropped: …`) |
| `@mirafive/sdk-server` | `track()` reports an `invalid_event` error to `onError`; `send()` rejects with it |
| PHP, Laravel, Symfony | `track()` and `send()` throw an `InvalidArgumentException` |

Keep properties free of personal data: no email addresses, names, phone numbers or free text a person typed. Use your own pseudonymous ids.

In PHP, an empty array `[]` is sent as a JSON list. Pass `new stdClass` where you mean an empty object.

## Revenue

Two property names have a meaning:

| Property | Type | Meaning |
| --- | --- | --- |
| `revenue` | number | The amount the event earned, for example `49.9` |
| `currency` | string | Its ISO 4217 code, for example `EUR`. Without one, the project's reporting currency applies |

Revenue is summed per currency and never added across currencies. Send it on the event that confirms the money, after the payment succeeds:

**Script tag**

```html
<script>
  mirafive('track', 'order completed', { revenue: 49.9, currency: 'EUR', items: 2 })
</script>
```

**Browser**

```ts
mira.track('order completed', { revenue: 49.9, currency: 'EUR', items: 2 })
```

**Node.js**

```ts
await mira.send(
  [{ name: 'order completed', userId: order.customerId, properties: { revenue: order.total, currency: 'EUR' } }],
  { idempotencyKey: `order-${order.id}` },
)
```

**PHP**

```php
$mira->send(
    [['name' => 'order completed', 'userId' => (string) $order->customer_id, 'properties' => ['revenue' => $order->total, 'currency' => 'EUR']]],
    idempotencyKey: 'order-'.$order->id,
);
```

**Laravel**

```php
use MiraFive\Laravel\Facades\Mira;

Mira::send(
    [['name' => 'order completed', 'userId' => (string) $order->customer_id, 'properties' => ['revenue' => $order->total, 'currency' => 'EUR']]],
    idempotencyKey: 'order-'.$order->id,
);
```

Purchases are best sent from the server: a browser event is lost when the visitor has an ad blocker or closes the tab, and a server can send the order once, however often a webhook arrives. See [Server-side events](https://docs.mirafive.io/guides/server-side#idempotency).

## Typed events

In TypeScript, describe your events once and `track()` checks names and properties. The types add no code to the bundle. `undefined` makes the properties optional:

```ts title="src/mira.ts"
import { createMira } from '@mirafive/sdk-browser'
import { pageviews } from '@mirafive/sdk-browser/pageviews'

type Events = {
  signup: { plan: 'free' | 'pro' }
  'order completed': { revenue: number; currency: string }
  logout: undefined
}

export const mira = createMira<Events>({
  key: import.meta.env.VITE_MIRAFIVE_KEY,
  plugins: [pageviews()],
})

mira.track('signup', { plan: 'pro' })
mira.track('logout')
```

The same type works with `new Mira<Events>()` from `@mirafive/sdk-server` and `useMira<Events>()` from the [React SDK](https://docs.mirafive.io/sdks/react#api-reference).

## Page URLs

Browser SDKs clean every page URL before sending it. The query keeps only these parameters, in their original encoding (names compared case-sensitively):

- every `utm_*` parameter
- `ref`, `source`
- `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid`, `ttclid`, `li_fat_id`

The fragment is dropped, unless hash mode is on; then it stays and a query inside it is cleaned the same way. A URL that does not parse loses everything from the first `?` or `#`.

So `https://shop.example/pricing?utm_source=news&email=max%40example.com#faq` is sent as `https://shop.example/pricing?utm_source=news`.

Server SDKs send `page.url` as you pass it. Clean it the same way before you pass it.

## Autocapture

Autocapture records clicks, form submits and changes as `$autocapture` events, in both modes. Turn it on with `data-autocapture` on the script tag, or the `autocapture()` plugin:

```ts
import { createMira } from '@mirafive/sdk-browser'
import { autocapture } from '@mirafive/sdk-browser/autocapture'
import { pageviews } from '@mirafive/sdk-browser/pageviews'

const mira = createMira({
  key: import.meta.env.VITE_MIRAFIVE_KEY,
  plugins: [pageviews(), autocapture()],
})
```

It listens on links, buttons, `input`, `select` and `textarea`, and elements with the role `button`, `link`, `tab` or `menuitem`. Each event describes the element: `$event_type` (`click`, `submit` or `change`), `$el_tag`, `$el_selector`, `$el_id`, `$el_classes`, `$el_text` (the label of links and buttons, up to 128 characters), `$el_href` (cleaned), `$el_name`, `$el_type`, and `$el_attrs` with any `data-testid`, `data-test`, `data-cy`, `data-qa` or `data-track` attribute. Generated class names and ids are left out.

It never records what was typed. Password, email and hidden inputs are skipped. To exclude part of a page, mark it:

```html
<div data-mira-no-capture>
  <button>Not recorded</button>
</div>
```

## Site search

Site search records what visitors search for on your site as `$search` events. It needs [full mode](https://docs.mirafive.io/guides/consent#full-mode) and statistics consent. Turn it on with `data-site-search` on the script tag, or the `siteSearch()` plugin:

```ts
import { createMira } from '@mirafive/sdk-browser'
import { identity } from '@mirafive/sdk-browser/identity'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
import { siteSearch } from '@mirafive/sdk-browser/search'

const mira = createMira({
  key: import.meta.env.VITE_MIRAFIVE_KEY,
  mode: 'full',
  plugins: [pageviews(), identity(), siteSearch()],
})
```

It reads the term from the `q`, `s`, `search` or `query` parameter of the page URL, before the URL is cleaned. Name your own parameters with `siteSearch({ parameters: ['term'] })` or `data-site-search="term"`. A term is sent once it has not changed for 1 second, or when the visitor moves on, so a live search that rewrites the URL per keystroke sends one event.

For a search that does not change the URL, call `mira.search(query)` or `mirafive('search', query)`. The server lowercases every term and replaces email addresses and runs of six or more digits.

## When events leave

Events are queued and sent in batches:

| SDK | A batch leaves |
| --- | --- |
| Script tag, browser SDK | 5 seconds after the first queued event, at 20 events, before a batch passes about 48 KB, and when the tab is hidden or the page is left |
| `@mirafive/sdk-server` | 1 second after the first queued event, or at 100 events. See [Server-side events](https://docs.mirafive.io/guides/server-side#batching-and-flushing) |
| PHP, Laravel, Symfony | At 100 events, and when the request, job or command ends |

In the browser, a batch sent while the page is hidden goes by `navigator.sendBeacon`. Call `flush()` (`mirafive('flush')`) to send the queue now. Browser options: `flushAt` (1–1000) and `flushAfterMs` (50–300000) on `createMira`.

A failed browser batch is tried up to three times in all, with backoff, honouring `Retry-After` up to 10 seconds. Every retry sends the same batch id, so the server stores it once.
