# Analytics

trackIntents() turns the intent on every element into one event shape: presses, submits, toggles, opens and closes, named by purpose. Prongs decides the shape; you decide where events go.

## What gets captured

Every Prongs component puts its intent on its element as `data-intent`. `trackIntents()` listens on the document and turns what people do with those elements into one event shape, the same on every page. Prongs decides the shape and how events are captured; you decide where they go, which ones, consent and sampling.

- `press` — A click, or a keyboard press, on an action, navigation or choice. Disabled ones are skipped.
- `toggle` — A press on something that stays on or off (aria-pressed), with its state after the press.
- `submit` — A form sent, named by the form’s intent: layout.form, or one of your own.
- `open` — A dialog or popover opening, named by its intent: dialog.confirm, popover.menu.
- `close` — The same closing again, however it closed: Escape, a click outside, or your code.

```ts
type IntentEvent = {
  type: 'press' | 'submit' | 'toggle' | 'open' | 'close';
  intent: string;        // what the element is for: "action.delete"
  within: string[];      // the intents around it, innermost first, at most 5
  path: string;          // location.pathname, never the query string
  state?: 'on' | 'off';  // toggles only: pressed after the press
  time: number;          // milliseconds since the epoch
};
```

A press counts for the nearest action, navigation or choice around what was clicked, so a click on the icon or heading inside a link counts for the link. The listeners run before your own handlers, so `within` still has the dialog a button was in, even when pressing it closes the dialog. A press on the delete button of a confirm dialog:

```json
{
  "type": "press",
  "intent": "action.delete",
  "within": ["layout.actions", "dialog.confirm", "layout.page"],
  "path": "/projects/42/settings",
  "time": 1760212800000
}
```

## Set it up

```bash
npx prongs add analytics
```

Copies `trackIntents()` into `components/prongs/analytics.ts`. It is your file, with no dependencies and no vendor SDK. Call it once on the client, in the root layout or a client provider. It is safe to import during server rendering, where it does nothing.

```tsx
// app/analytics.tsx
'use client';

import { useEffect } from 'react';
import { trackIntents } from '@/components/prongs/analytics';

export function Analytics() {
  // trackIntents returns the function that stops it, which is what an effect cleans up with.
  useEffect(() => trackIntents((event) => console.log(event)), []);
  return null;
}

// app/layout.tsx: render <Analytics /> once, inside <body>.
```

It reads the attribute the components write, so `intentAttribute` in `components/prongs/config.ts` has to stay `'always'`. With `'development'`, production pages carry no intents and nothing is captured.

## Recipes

Each recipe is the callback and nothing else. Load the tool the way its own docs say.

Google Tag Manager and GA4

```ts
trackIntents((event) => {
  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push({ event: 'intent', ...event, within: event.within.join(' > ') });
});
// In Tag Manager: a Custom Event trigger on "intent", Data Layer Variables for
// type, intent and within, and a GA4 event tag that sends them as parameters.
```

PostHog

```ts
import posthog from 'posthog-js';

posthog.init(KEY, { autocapture: false }); // autocapture collects element text; this does not
trackIntents((event) => posthog.capture(`intent ${event.type}`, event));
```

Segment

```ts
trackIntents((event) => analytics.track(`Intent ${event.type}`, event));
```

Plausible

```ts
// Plausible properties are strings, and every event counts against your plan: keep presses and submits.
trackIntents(
  (event) => window.plausible?.('Intent', { props: { type: event.type, intent: event.intent, within: event.within.join(' > ') } }),
  { include: (event) => event.type === 'press' || event.type === 'submit' }
);
```

## What it is good for

**Funnels that survive a redesign.** A step defined as `press action.start` keeps counting when the button moves, changes colour or gets new copy. Selectors on classes or text break on each of those; an intent only changes when the button stops doing that job.

```text
// Sign-up funnel, as steps your analytics tool can count
press   action.start   path /
submit  layout.form    path /signup
press   action.next    path /signup/team   within layout.form
```

**Feature usage by intent.** Count presses per intent to see what gets used, and group by `within` to see where: the same [`action.share`](https://www.prongs.dev/docs/intents/action/share.md) pressed in a [`popover.menu`](https://www.prongs.dev/docs/intents/popover/menu.md) or a [`layout.toolbar`](https://www.prongs.dev/docs/intents/layout/toolbar.md). An intent names a kind of element, not one button: `action.save` is on every form. Add `path` to count one screen, and give a step you track closely an intent of its own.

**Joining a press to the error it caused.** Responses from [`fail()`](https://www.prongs.dev/docs/profiles/errors.md) carry `code`, the error intent. Send it with the press that led to it, as below, and a dashboard can count which presses end in [`conflict.state`](https://www.prongs.dev/docs/intents/conflict/state.md) or [`record.missing`](https://www.prongs.dev/docs/intents/record/missing.md) without a mapping table. The link is the one you write: Prongs does not connect the two on its own.

```ts
const response = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!response.ok) {
  const { code } = await response.json(); // "conflict.state", the intent fail() was called with
  posthog.capture('intent error', { code, after: 'action.delete' });
}
```

**Dead clicks.** Clicks on things that do nothing, like an image or a topic people expect to open, show what the page promises and does not deliver. A second tracker with other prefixes reports them, leaving out anything inside a real action.

```ts
// A second tracker for clicks on things that are not actions: an image, a heading, a topic.
const pressable = /^(action|nav|choice)\./;
trackIntents((event) => posthog.capture('dead click', event), {
  prefixes: ['media.', 'heading.', 'text.'],
  include: (event) => !event.within.some((intent) => pressable.test(intent)),
});
```

**A/B targeting.** Experiment tools can target `[data-intent="action.start"]` to change copy, and measure the result with the same event. Add the variant in your callback.

```ts
trackIntents((event) => posthog.capture(`intent ${event.type}`, { ...event, variant: getVariant('hero-copy') }));
```

## From events to decisions

The events are only worth collecting if they change what you build. Two examples, with made-up numbers, of reading them and deciding something.

**A confirm people back out of.** Count what happens after each [`dialog.confirm`](https://www.prongs.dev/docs/intents/dialog/confirm.md) opens: the press inside it, or a close with none.

```text
// Delete confirmations on /projects/:id/settings, one month (made-up numbers)
open    dialog.confirm                           1,240
press   action.delete   within dialog.confirm      410   33%
press   action.cancel   within dialog.confirm      610   49%
close   dialog.confirm  with no press inside       220   18%
```

Two in three people who open it leave without deleting. If they came for something else, like archiving a project, that is a missing `action.archive`. If they were unsure, the dialog should say what goes and what stays. Either way the next version is measured by the same four lines.

**A feature in the wrong place.** Group presses on one intent by where they happened.

```text
// Presses on action.share, grouped by the first intent in within
within popover.menu     3,920
within layout.toolbar     880
```

Sharing is mostly found in the menu, so the toolbar button earns little of its space. Before moving it, check the opposite reading: the toolbar may be quiet because it is hidden on small screens. Split by `path`, or by device in your tool, to tell them apart.

**What the events cannot tell you.** There is no event for an element being seen, only for it being used, so a button nobody noticed and a button nobody wanted look the same. Rates need a denominator from elsewhere: page views from your analytics tool, or the `open` of the dialog around it. Events carry no user, so per-person questions, like how many people turn a setting on, depend on the identity your tool adds.

## Privacy and consent

Nothing the user wrote or read is captured: no text, labels, input values or other content, only intent ids, which name what an element is for and are the same for everyone. The path is `location.pathname`, never the query string or hash, where emails and tokens tend to end up. There are no user ids; whatever identity your tool adds is its own setting.

Consent and sampling live in your code. Return false from `include` to drop an event, or check consent before calling `trackIntents` at all.

```ts
const sampled = Math.random() < 0.1; // one visit in ten

trackIntents(send, {
  include: (event) => hasConsent('analytics') && sampled && event.type !== 'close',
});
```

## Never for styling

`data-intent` is for finding elements, not styling them. A selector on it in CSS or in a class would be a second place that decides how something looks, and `prongs check` reports it as `intent-selector`. [More on targeting elements](https://www.prongs.dev/docs/targeting.md).
