# Auto-Collected & Custom Events

The tracker captures a lot of behavior automatically so most sites don't need to write any custom event code at all. When you do need custom events, the `karma()` JavaScript API is the only surface.

## Common Event Envelope

Every event the tracker emits — auto or custom — ships with the same envelope:

```json
{
  "event_id":   "550e8400-e29b-41d4-a716-446655440000",
  "event_name": "page_view",
  "event_kind": "auto",
  "event_ts":   "2026-05-22T03:45:01.234Z",
  "visitor_id": "v_8f2c1a09",
  "session_id": "s_4d77b1e2",
  "user_id":    null,
  "page_url":   "https://customer.example/pricing?utm_source=twitter",
  "page_path":  "/pricing",
  "page_title": "Pricing - Customer",
  "referrer":   "https://t.co/abc123",
  "utm":        { "source": "twitter", "campaign": "launch" },
  "properties": { /* event-specific */ },
  "consent":    { "analytics": null, "marketing": null, "dnt": false, "gpc": false }
}
```

- `event_kind` is one of `auto` | `custom` | `tag` | `s2s`. Only `custom` and `tag` events should appear in funnels / event definitions; auto events are the raw stream.
- `visitor_id` is a UUID stored in `localStorage` per origin (lasts 2 years).
- `session_id` is a 30-minute idle-timeout UUID stored in `localStorage`.

## Auto-Collected Events

### `page_view`

Fired on initial load and on every SPA navigation (`pushState`, `replaceState`, `popstate`). Carries the standard envelope only — no extra `properties`.

For SPA navigations, the tracker flushes any pending web-vitals events with `page_url` / `page_path` overrides set to the *previous* page before emitting the new `page_view`. This makes per-page LCP / CLS / INP correct even on long-lived single-page apps.

### `click`

Fired on every `mousedown` (not `click` — captures right-click and middle-click too). Properties:

| Property | Source |
| --- | --- |
| `tag` | Lowercased `tagName` of the clicked element. |
| `id` | Element `id` attribute, if any. |
| `class_list` | First 4 classes joined with space, max 256 chars. |
| `text` | Trimmed `innerText`, max 256 chars. |
| `href` | Anchor / form-action URL, if present. |
| `selector_path` | Hand-rolled CSS selector — up to 5 levels of `tag.class:nth-of-type(N)`. Used by the AI element picker and by Tag Manager triggers. |
| `is_outbound` | `true` if `href` host differs from `location.host`. |

Outbound clicks also fire a synthetic `outbound_click` event with the same properties so funnels can target them without a custom filter.

### `scroll_depth`

Fired at the first time the visitor crosses 25%, 50%, 75%, or 100% of the document height. Each threshold fires at most once per page. Property: `{ depth_pct: 25 | 50 | 75 | 100 }`.

### `form_submit`

Fired on `submit` of any `<form>` on the page (capturing phase, so it sees Submit even when the form's own handler `preventDefault()`s it). Privacy-safe by design:

| Property | Source |
| --- | --- |
| `form_id` | Form `id` attribute. |
| `form_name` | Form `name` attribute. |
| `action` | Form `action` URL. |
| `field_names` | Array of input `name` attributes — values are never collected. |
| `has_password` | `true` if any `<input type="password">` is present. |
| `field_count` | Total number of inputs/selects/textareas. |

Values are intentionally never collected — only structure. To collect form *outcomes*, fire a custom event in your own `onsubmit` handler.

### `web_vitals`

The tracker uses `PerformanceObserver` to capture Core Web Vitals. The first batch is reported on `pagehide` / `visibilitychange: hidden`; subsequent batches are also flushed on SPA navigation with `page_url` / `page_path` overrides so per-page numbers are correct.

| Property | Source |
| --- | --- |
| `metric` | `'lcp'` \| `'cls'` \| `'inp'` |
| `value` | LCP / INP in milliseconds; CLS as unitless float. |
| `element_selector` | Selector of the element that drove the metric (LCP target, layout shift source, INP interaction target). |

CLS uses session-window accumulation per the spec; layout shifts within 500 ms of user input (`hadRecentInput: true`) are excluded. INP uses the longest interaction (`interactionId`) across the page.

### `js_error`

Fired on `window.onerror` and on `unhandledrejection`. Properties:

| Property | Source |
| --- | --- |
| `message` | First 1 KB of error message. |
| `source` | Script URL where the error originated. |
| `lineno` | Line number. |
| `colno` | Column number. |
| `stack` | First 4 KB of stack trace. |
| `error_kind` | `'error'` \| `'unhandledrejection'`. |

A per-page dedupe limit (`DEDUPE_LIMIT = 10`) caps the number of distinct errors reported per page-view to avoid drowning the collector when a third-party script is in a loop.

### `history_change`

Fired on every `pushState` / `replaceState` / `popstate`. The tracker also fires a fresh `page_view` immediately after — this event exists so SPA-routing-only analyses (e.g. "how often does the route change without a page_view succeeding?") are possible.

## The `karma()` JavaScript API

The tracker exposes one global function: `karma()`. It accepts a command name as the first argument:

### `karma('event', name, properties, pageOverrides)`

Emit a custom event. The `name` is required and goes through the same 80-char / non-empty validation as auto events. `properties` is an arbitrary JSON object capped at 16 KB.

```js
karma('event', 'signup', {
  plan: 'pro',
  source: 'pricing_page',
  amount_usd: 49,
});
```

`pageOverrides` is rarely needed — use it to override `page_url` / `page_path` / `page_title` / `referrer` when the current location values are wrong (e.g. you're firing a delayed conversion after a navigation already happened).

### `karma('page', properties, pageOverrides)`

Emit a `page_view` manually. Useful when you want to attribute an SPA route change to a custom name — `pageOverrides` can rewrite the page fields:

```js
karma('page', { variant: 'experiment-A' }, { page_path: '/checkout/step-2' });
```

### `karma('identify', userId, traits)`

Bind the current `visitor_id` to a known user. After this call, every subsequent event includes `user_id`, and an `identify` event is emitted so the identity-stitching job can backfill historical visitor activity to the same user.

```js
karma('identify', 'user_12345', { email: 'jane@example.com', plan: 'pro' });
```

Calling `identify` with a different `userId` later in the same session re-binds without losing history; the `identity_map` BQ table records the timeline.

### `karma('consent', state)`

Update the consent state. The tracker tracks `analytics` and `marketing` flags; until set, both are `null` (which the server interprets as "consent not given"). DNT and GPC are read live from the browser, not from this state.

```js
// Typical cookie-banner flow:
karma('consent', { analytics: true, marketing: false });
```

The `consent` block is included in *every* subsequent event so server-side filters can drop or keep events per-event, not just per-session.

### `karma('flush', reason?)`

Force-flush the current queue. The tracker auto-flushes every 2 s, on every 20-event batch fill, and on `pagehide` / `visibilitychange: hidden`. Use `karma('flush')` only when you're about to navigate via something the browser doesn't fire `pagehide` for (extremely rare).

## How Batches Are Sent

- Queue accumulates up to 20 events, then auto-flushes via 2-second timer or batch-full.
- Transport priority: `navigator.sendBeacon` first (text/plain blob to dodge CORS preflight), `fetch` with `keepalive: true` as fallback, GET pixel beacon (`/collect/:siteKey/p?e=<b64>`) as last resort.
- The server returns `202` with `{ success, accepted, filtered }`. `filtered > 0` usually means events were dropped server-side due to DNT/GPC.

## What's Not Auto-Collected (and Why)

- **Form values** — would leak PII; only field names + counts are sent. Fire a custom event for outcomes.
- **Mouse coordinates / heatmap data** — out of scope for this module; not in MVP.
- **Session replay** — same.
- **Cross-site tracking** — `visitor_id` is per-origin in `localStorage`. There is no third-party cookie.
- **Full URL query strings** — the tracker truncates `page_url` to 2 KB but doesn't redact specific params. If a query parameter contains PII, redact it in your own URL before navigating.
