# Hoot Lens tracker

The tracker records DOM snapshots and interactions with rrweb's recorder, plus click, scroll and snag evidence for heatmaps. It does not record screen video, read console logs or capture network requests. The owl describes observed interaction signals, never emotions.

What is recorded depends on each project's privacy policy and consent mode, which the customer sets in the dashboard and the tracker loads from the collector at startup.

## Install

The whole install is one script tag. No inline JavaScript, route list, data attributes or framework hooks are needed.

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

Optional attributes:

| Attribute | Meaning |
| --- | --- |
| `data-endpoint` | Collector base URL. Defaults to the production API. Must be HTTPS (or `http://localhost`). |
| `data-release` | Release identifier. Without it, the tracker derives `auto-<hash>` from the framework's build id or the site's main entry files (see below). |
| `data-consent` | `granted` or `denied`, when the site already knows the visitor's decision at page load. |
| `data-consent-source` | `onetrust`, `cookiebot`, `gcm` or `hootlens`: follow that consent platform, or show the built-in "Record my visit" control. |
| `data-consent-category` | The platform category that means "record". Defaults: OneTrust `C0002`, Cookiebot `statistics`, Google Consent Mode `analytics_storage`. |
| `data-path-template` | Public route label recorded instead of the path, such as `/pages/:slug`. Same rules as the `pathTemplate` option. |
| `data-notice` | `corner` or `corner-right` turns on the recording notice. Off by default. |
| `data-notice-text` | Notice wording, plain text, at most 120 characters. |

`tracker.js` is served as an alias of `t.js` for existing installs.

### Commands

Commands use a queue that works before and after the script loads, so no stub is needed:

```html
<script>
  (window.hootlens = window.hootlens || []).push(['consent', true]);
</script>
```

| Command | Effect |
| --- | --- |
| `['consent', true]` or `['consent', false]` | The visitor's explicit choice. Withdrawal stops capture, discards unsent data and stays stopped until `['consent', true]`. Granting starts or resumes capture, including after a `stop`. The tracker stores the choice and re-applies it on every load. |
| `['consent', 'reset']` | Clears the stored choice, so the project's default applies again. |
| `['consentSource', 'onetrust', 'C0003']` | Same as `data-consent-source` and `data-consent-category`; the category is optional. |
| `['diagnostics', fn]` | Calls `fn` with the diagnostics object (see Debug overlay), or `undefined` before the tracker has started. |
| `['release', 'v42']` | Sets the release label. A change starts a new segment. |
| `['variant', 'checkout-b']` | Sets the experiment variant. |
| `['pageVersion', 'pricing-v3']` | Sets the page edition. |
| `['flush']` | Sends buffered data now. |
| `['stop']` | Stops capture until `['start']` or `['consent', true]` is pushed. Data already captured is delivered first. When a site pauses on `pointerdown` for a private control, the press that triggered the pause is still recorded as a click (`inferred: "pointerdown"`), identified only by site-authored IDs (`data-hoot-id`, `data-hoot-name`, `data-hoot-label`, `aria-label`) in private regions. |
| `['start']` | Resumes after a `stop` in the same session with a new segment. A withdrawn consent stays withdrawn, and a project that requires consent still waits for it. |
| `['onStatus', fn]` | Calls `fn(status)` now and on every change. See Status for the host page below. |

`window.Hootlens` exposes the same programmatic API as the npm package, including `status()` and `onStatus(fn)`. Its `stop()` pauses capture and keeps the tracker, so `setConsent(true)` or `getTracker().start()` resumes it.

### Status for the host page

`window.Hootlens.status()` returns `{state, consentMode, consentReason?, consent, consentSource, reason?}` synchronously, and `['onStatus', fn]` (or `onStatus(fn)` from the module) calls `fn` immediately and after every change. Changes within one tick are delivered once, and a callback that throws never affects capture.

| `state` | When |
| --- | --- |
| `loading` | Before the tracker starts or while the project settings load. |
| `waiting-consent` | Consent is needed and was not given, or the visitor has not been allowed to record. |
| `recording` | Capture is running. |
| `paused` | The collector answered `402`: the project is not accepting recordings. `reason: 'capture-paused'`. |
| `not-targeted` | The page is outside the project's recorded pages. `reason: 'not-targeted'`. |
| `disabled` | `reason` is `gpc`, `dnt` or `sampled-out`. |
| `stopped` | After `stop`, or after `consent` false. |
| `error` | The collector refused the page (`reason: 'collector-rejected'`) or the recorder could not run. |

`consentMode` is the mode in force for this visitor, `implied` or `required`, once settings have loaded. For a regional project the API resolves it from the visitor's region, and `consentReason` says why: `region`, `region-unknown` (asked first) or `project`. `consent` is the decision reported or stored, or `undefined`. `consentSource` is `visitor` (the visitor's choice, stored by the tracker or reported by the site), `cmp` (a platform adapter) or `project` (no decision yet). When the settings could not be loaded, the strict fallback is in force and `reason` is `config-unavailable`. When an agent installed the tag before the site owner confirmed its setup email, the collector refuses with `project_pending`, the state is `error` and `reason` is `project-pending`; reload the page after the owner confirms. The status never reveals the sample rate, privacy preset, selectors or page rules. [INSTALL.md](INSTALL.md) has a consent toggle built on it.

### Stop, start and consent

| From | Action | To |
| --- | --- | --- |
| recording | `stop` | `stopped` |
| `stopped` | `start` | `recording`, or `waiting-consent` when consent is required and missing, in the same session with a new segment |
| `stopped` | `consent` true | `recording` in the same session with a new segment |
| recording | `consent` false | `stopped`, unsent data discarded, stored session and config cleared |
| `stopped` after `consent` false | `start` | stays `stopped` |
| `stopped` after `consent` false | `consent` true | `recording` in a new session, because withdrawal cleared the stored one |
| `stopped` | `stop` or `consent` false | stays `stopped` |

A stop pushed before the script loads holds: the tracker is created and left stopped.

### Plain HTML

Place the tag in `<head>` on every page.

```html
<head>
  <script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
</head>
```

### Next.js

App Router, in `app/layout.tsx`:

```tsx
import Script from 'next/script';

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="https://hootlens.com/t.js" data-project="PROJECT_ID" strategy="afterInteractive" />
      </body>
    </html>
  );
}
```

A plain `<script async src=... data-project=...>` in `<head>` also works, in the App Router or in `pages/_document.tsx`.

### Nuxt

In `nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [{src: 'https://hootlens.com/t.js', async: true, 'data-project': 'PROJECT_ID'}],
    },
  },
});
```

### SvelteKit

In `src/app.html`, inside `<head>`:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

### Angular

In `src/index.html`, inside `<head>`:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

### Astro

In the shared layout, for example `src/layouts/Layout.astro`:

```astro
<head>
  <script is:inline async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
</head>
```

`is:inline` stops Astro from bundling the external script.

### WordPress

Add the tag to the theme's `header.php` before `</head>`, or use a header and footer snippet plugin:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

### Shopify

In `layout/theme.liquid`, before `</head>`:

```liquid
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

### Google Tag Manager

Create a Custom HTML tag that fires on All Pages:

```html
<script async src="https://hootlens.com/t.js" data-project="PROJECT_ID"></script>
```

GTM injects the tag dynamically, so the tracker finds its settings through `script[data-project]` when `document.currentScript` is unavailable. To follow a consent platform, set `data-consent-source` on the injected tag instead of pushing consent from a second tag.

### Content Security Policy

Allow the script host and the collector origin:

```
script-src 'self' https://hootlens.com;
connect-src 'self' https://YOUR_COLLECTOR_ORIGIN;
```

Replace `YOUR_COLLECTOR_ORIGIN` with the API origin in `data-endpoint`, or the production API origin when that attribute is absent.

### npm

```ts
import {init, setConsent} from '@hootlens/tracker';

init({projectId: 'PROJECT_ID'});
// Later, from the consent banner:
setConsent(true);
```

The module can be imported during server rendering; it touches `window` and `document` only when `init` or `start` runs in a browser. `@rrweb/record` is loaded with a dynamic import, so bundlers split it from the initial chunk. `createTracker(options)` remains available and returns a tracker with `start(consent?)`, `setConsent`, `setLabels`, `flush`, `stop`, `status` and `config`. `start(true)` grants consent and replaces the earlier `captureAllowed` permission, which is still accepted.

`status()` returns `idle`, `waiting-consent`, `recording`, `page-excluded`, `disabled` (Global Privacy Control, Do Not Track or sampled out), `stopped` or `error`. `hostStatus()` returns the host-facing object described under Status for the host page, and `onStatus(listener)` subscribes to it.

## Consent

Each project has a consent mode:

- `implied`: records unless the visitor has opted out.
- `regional`: the API resolves `required` for visitors in the project's listed regions (or when the region is unknown) and `implied` for everyone else. The tracker only ever sees the resolved mode.
- `required`: records only after the visitor consents.

### The tracker owns the choice

`['consent', true|false]` (or `setConsent` through `Hootlens`) is an explicit visitor choice. The tracker stores it in `localStorage` under `hootlens:consent:<projectId>` as `{v, at}` inside try/catch and applies it on every load, so a site does not store it or push it again. A site that does keep pushing it works as before. `['consent', 'reset']` removes it. `['stop']`, `['start']` and navigation never change it, so a stopped page records again on the next full load. `data-consent` is a server-rendered decision for that page load and is not stored. Order of precedence on load: a consent command pushed this load, then `data-consent`, then the stored choice, then the notice's legacy `out` flag. A consent platform adapter, when configured, overrides all of these as soon as it reports a decision.

### Consent platform adapters

`data-consent-source` (or `['consentSource', name, category]`) starts one small adapter. The code ships in `t.js` but does nothing unless configured.

| Source | Reads | Default category |
| --- | --- | --- |
| `onetrust` | `OnetrustActiveGroups` (comma separated active category IDs), checked when `OptanonWrapper` runs, which is chained to any wrapper already defined, and once a second | `C0002` |
| `cookiebot` | `CookiebotOnAccept`, `CookiebotOnDecline`, `CookiebotOnConsentReady`, then `Cookiebot.consent[category]` once `Cookiebot.hasResponse` is true | `statistics` |
| `gcm` | `consent` commands (`default` and `update`) in `dataLayer`, existing and later | `analytics_storage` |
| `hootlens` | Nothing: shows the "Record my visit" control | none |

Platform decisions have `consentSource: 'cmp'`, are never written to Hoot Lens storage and are ignored when they repeat the previous value. Until a platform reports, the mode's default applies. Google does not document a way to read consent state back, so the `gcm` adapter sees only commands your page pushes to `dataLayer`: region-scoped defaults are skipped, an `update` beats a `default`, and a change made only inside Tag Manager's consent API may not be seen. Sources: OneTrust [client-side cookie management](https://my.onetrust.com/articles/en_US/Knowledge/UUID-518074a1-a6da-81c3-be52-bae7685d9c94) and [Tag Manager guide](https://developer.onetrust.com/onetrust/docs/google-tag-manager), [Cookiebot developer page](https://www.cookiebot.com/en/developer/), [Google consent mode](https://developers.google.com/tag-platform/security/guides/consent).

### Notice

`data-notice` or `['notice', true]` (optionally `['notice', 'corner-right', 'text']`) shows a fixed corner pill in a closed shadow root, marked `data-hoot-private` so replays leave it out. It shows once, when the status is `recording`, `consentMode` is `implied` and no decision exists. "Opt out" pushes `['consent', false]`, which is stored, and also stores `out` under `hootlens:notice`. The dismiss button stores `seen`. With the `hootlens` source the same control becomes "Record my visit" after an opt out, and on a `required` project it asks first. The text is set as text, never HTML. It uses a constructed stylesheet, so a strict `style-src` does not break it, and has no animation.

### Debug overlay and diagnostics

`?hootlens=debug` on any page, or `localStorage` `hootlens:debug` set to `1`, shows a small panel in a closed shadow root marked `data-hoot-private`. Its styles are set through the CSSOM, so a strict `style-src` does not break it. It lists `state`, `reason`, `consentMode`, `consentReason`, `consent`, `consentSource`, `cmp`, `projectId`, `origin`, `pageTargeted`, `privacyPreset`, `configSource` (`fallback` when settings could not load) and `lastUpload` (`ok`, HTTP `status`, the collector `code` and its refusal `message`). "Copy diagnostics" copies the same object as JSON. It is drawn at the maximum z-index, above site banners. It never contains page content or the page path. `['diagnostics', fn]` and `Hootlens.diagnostics()` return it too.

Global Privacy Control and Do Not Track always disable capture, whatever the mode or consent. No session ID is written before capture is permitted.

If the configuration cannot be loaded (network failure, unknown project or an invalid response), the tracker fails safe to the strict preset with required consent.

## Privacy presets

| Preset | Text | Images, SVG, video | Styles | Custom elements and open shadow DOM |
| --- | --- | --- | --- | --- |
| `full` | Recorded | Recorded by URL, never inlined | Same-origin stylesheets inlined; others by URL | Recorded |
| `balanced` | Recorded, with emails, phone numbers, card-like numbers and digit runs of six or more replaced by `*` | As full | As full | Recorded |
| `strict` | Fixed `••••` placeholders | Blocked | Allowlisted declarations only | Blocked in replay |

A fixed floor applies to every preset and cannot be turned off:

- Password fields, fields that were ever password fields, and fields whose `autocomplete` contains `cc-*`, `current-password`, `new-password` or `one-time-code` are never recorded. Their values are replaced with an empty string.
- `[data-hoot-private]` regions are replaced by an empty placeholder of the same size. Clicks inside them are attributed to the region, not to its contents.
- Every other form value, including hidden inputs and editable regions, is masked with `*` unless the field matches one of the project's `unmaskInputSelectors`. Strict ignores the unmask list. In `balanced`, allowlisted values still pass through the balanced redaction, so an email typed into an allowlisted search box is recorded as `*`. Checkbox and radio input events carry no value, and the `value` attribute of an input, textarea or select is masked again when a snapshot is re-taken (for example after the visitor cleared a prefilled field). Page-authored values on `progress`, `meter`, `li`, `option`, `button` and checkbox, radio and button inputs are recorded as written.
- Page URLs never include fragments. Query strings are kept only when the project enables `captureQueryStrings`. `user:password@` is removed from every recorded URL.
- URL attributes in the recorded DOM always lose their fragment, so `href="#faq"` is recorded as an empty value, and always lose `user:password@`. The query string depends on the attribute. Navigational attributes (`href`, `action`, `formaction`, `xlink:href`, `cite`, `longdesc`, `ping`, `manifest`, `codebase`) lose it unless the project enables `captureQueryStrings`, because reset tokens and identifiers live there. Resource attributes (`src`, `srcset`, `imagesrcset`, `poster`, `data`, `background`, `icon`, `lowsrc`, `dynsrc`) and `url(...)` references in inline `style` attributes, inlined stylesheet text (`_cssText`) and `<style>` text keep their query string in `full` and `balanced`, so image resizers, `/_next/image?...` and signed CDN URLs still load in replay. `strict` records none of these. Inline `data:` image URLs are left as they are, and CSSOM rule changes made after load are not rewritten. Resource URLs can carry identifiers or signatures; avoid putting them in image addresses you consider private. The collector rejects any DOM URL attribute with a fragment, and a navigational one with a query string when it is not allowed.
- Balanced redaction does not touch resource attributes (`src`, `srcset`, `imagesrcset`, `poster`, `background`), whose values are file names and image resizer queries, or SVG geometry and paint attributes (`d`, `points`, `transform`, `viewBox`, coordinates, sizes, opacities and dash arrays) whose value is made only of numbers, path commands and units. Redacting them used to read a long path as a phone number and star it out, which left logos and icons undrawn in replay. Text-bearing attributes (`title`, `alt`, `aria-label`, `href` of a link, `data-*`) are still redacted, and a geometry attribute holding anything but numbers and path syntax is too.

Customers can add `maskTextSelectors` (text replaced by `*`) and `blockSelectors` (element replaced by a placeholder). If any of these selectors is invalid in the visitor's browser, the tracker uses the strict preset for that page. Invalid unmask selectors are ignored.

Balanced redaction also applies to attribute values (such as `title`, `alt`, `aria-label` and `href`) and to the recorded path. Strict redacts the recorded path the same way. The redaction patterns are heuristics: a sensitive value in an unusual format can still appear, so use `maskTextSelectors` or `[data-hoot-private]` for known sensitive areas.

`full` records what visitors see, including personal data rendered on the page. Choose it only where the site's own privacy terms allow that.

Strict keeps the original pilot sanitizer: an attribute allowlist, sanitized inline stylesheets parsed without loading imports, and a bounded computed-style probe (at most 250 elements and 96 KiB per snapshot, 32 elements and 16 KiB per mutation) so colours from CSS variables survive. Computed styles are read only in strict mode.

Canvas content, cross-origin iframes and font files are not recorded. A hero or background drawn on a canvas or WebGL surface therefore replays blank (a site that uses plain images, including Next.js `/_next/image` URLs, replays them through the asset proxy); rrweb canvas recording would send frames or draw commands and is not enabled. A video replays as its poster image only. In full and balanced, same-origin iframes are recorded under the same policy; strict blocks all iframes.

## Pages, segments and heatmaps

Each page view is a segment with its own full snapshot. A new segment starts when:

- the page loads or is restored from the back-forward cache;
- the path changes through `history.pushState`, `history.replaceState` or `popstate`. The snapshot waits briefly for the client router to render so heatmap backgrounds show the new route;
- the visitor navigates from a page that is not recorded back to a recorded one (see Page targeting);
- the device class changes on resize (see Device class);
- a public `data-hoot-region` / `data-hoot-version` pair changes, or the release, variant or page version label changes.

Changes that describe the same page view do not split it. `pushState` or `replaceState` to the same path (and the same query when the project records queries) starts nothing. A resize inside one device class starts nothing. A server config with the same effective privacy policy (selectors in another order included) restarts nothing. A region marker, label or device class change that arrives before the segment has sent anything or recorded a click, as on a framework page that hydrates, a consent banner that renders or a late `setLabels`, updates the unsent segment in place instead of sealing a stub next to a second one. A segment that has no replay events and no clicks is never sent, so scroll depth and pointer dwell alone do not create one.

Hash changes do not start a segment because fragments are not part of the path. Window resizes within a device class do not start a segment, and the revision never includes viewport size.

### Page fingerprint

Each segment carries `pageFingerprint`: 16 hex digits hashing the page's visible structure, read once at the segment's first snapshot, before its first batch is sent. The collector turns it into the page version (`docs/API.md`, Page versions): the version changes when this page's fingerprint changes, not when the site is redeployed.

What is hashed, from the main content (`<main>`, `[role=main]`, else `<body>`): the tag tree with plain `div` and `span` wrappers flattened away, the order and counts of headings (by level), buttons and links, and `data-hoot-*` markers with safe values. Under the `full` and `balanced` presets, the text of headings, paragraphs, list items, table cells, links, buttons, labels and captions, and `alt` and `aria-label` text, lower-cased with digits, currency amounts, month names and extra whitespace removed, so counters, prices and dates are not edits. A rewritten paragraph or alt text is an edit. Under `strict` no text is read at all: structure only. Balanced replays already show page text with contact details and numbers removed, so reading its normalized text into a one-way hash reveals nothing more. Text is hashed and discarded in the browser; only the final hash is sent, and it carries no page content.

Content that differs on every load is kept out so it does not make a new version: a region marked `data-hoot-dynamic` (see below), live regions (`aria-live` other than `off`, `role` of `timer`, `log`, `status`, `alert` or `marquee`), `<time>`, `<output>`, `<progress>` and `<meter>`, and carousels and sliders (`aria-roledescription` naming a carousel, slider or slide, or an `id` or `class` naming a carousel, swiper, slick slider, marquee, ticker, rotator, countdown, clock or timer). Such a region stays in the structure as one placeholder token with nothing hashed inside it, so removing it is still an edit. A run of two or more consecutive siblings with the same structure (a list of posts, product cards, search results) counts as one item without its text, so a list that is longer on one load than the next is one page.

Text that differs on every load (a random sentence, a rotating quote) is handled by the collector rather than the hash: the collector adds a second safeguard: a new fingerprint is only a candidate until it has been the page's fingerprint on 3 visits in a row (or 2 visits 6 hours apart), and visits on a candidate are counted in the page's current version. See `docs/API.md`, Page versions.

#### `data-hoot-dynamic`

Put `data-hoot-dynamic` on any region that shows something different on each load: a rotating hero or testimonial, a "latest news" block, a personalised greeting, a live count. The region keeps its place in the page's structure but nothing inside it is hashed, so its changes never start a new page version.

```html
<section data-hoot-dynamic>
  <h2>Meet our new team</h2>
</section>
```

The attribute carries no value and sends nothing: it only tells the tracker to leave that region out of the fingerprint. It is privacy neutral. It does not hide the region from replays or heatmaps; use `data-hoot-private` for that. Use it on the smallest region that rotates, not on the whole `<main>`, or a real edit to the page will no longer be noticed.

What stays out: scripts, styles, hidden and `aria-hidden` elements, iframes, objects and embeds, SVG, canvas and media internals, cookie and consent banners and dialogs (by `role`, or an `id` or `class` naming cookie or consent), `[data-hoot-private]`, masked, blocked and editable regions (their position counts, nothing inside them), form values, classes, ids and every other attribute. The work is bounded at 500 elements and 32 levels, so it stays cheap on large pages. A page with no `<main>` hashes its whole body, so a header change then counts. A fingerprint that still never settles is treated as unstable by the collector rather than splitting the page's data. Older trackers send none and the release is the version.

### Goals

When the project has selector goals, the config lists them (`{id, selector}`) and the tracker tests each click's element and parents against them with `matches`, sending the ids of the goals matched as `click.goals`. Only ids leave the browser. A click on a control inside a `[data-hoot-private]` or blocked region is tested only against selector goals made of `data-hoot-id`, `data-hoot-name`, `data-hoot-label` or `aria-label` attribute selectors, and only on the clicked element itself. Label, `data-hoot-id` and page goals are decided by the collector from the click's label, target and the page's path.

### Device class

The tracker sends `segment.deviceClass`, decided by the input hardware before the width: a fine pointer with hover (`(pointer: fine)` and `(hover: hover)`, so no `(pointer: coarse)` and no `(hover: none)`) is `desktop` at any width, so a 1004 px desktop window is not a tablet. A coarse pointer or no hover is `mobile` below 768 CSS pixels and `tablet` from 768. Where `matchMedia` gives no answer, `navigator.userAgentData.mobile` and then `navigator.maxTouchPoints` decide; with none of these the old width rule applies (under 768 mobile, under 1024 tablet). Only the class is sent, never the hints. Older trackers send no class and the collector derives it from the viewport width with that old rule.

Heatmaps group by page group (a logical page across languages and variants), release, revision and device class; language and variant are dimensions inside the heatmap. See [Language and variants](#language-and-variants). The revision is the page version (default `page`) plus a hash of public region markers when present. The `data-path-template` attribute and the npm `pathTemplate` option replace the recorded path with a route label such as `/projects/:project`.

Automatic release detection gives one value per deploy, the same on every page of it, in this order (`packages/tracker/src/release.ts`). An explicit `data-release` or `['release', ...]` always wins.

1. The framework's own build id: Next.js (`__NEXT_DATA__.buildId` for the Pages Router, the `"b"` value in the inline flight data for the App Router, or a `/_next/static/<buildId>/` asset path) and Nuxt (`__NUXT__.config.app.buildId`).
2. Otherwise a hash of the pathnames of the main entry files only: same-origin scripts and stylesheets named `main`, `main-app`, `index`, `app`, `entry`, `runtime`, `webpack`, `polyfills`, `vendor(s)`, `framework`, `bundle` or `common(s)`, with query strings ignored. Route chunks and lazily loaded chunks are left out: which of them are in the page when capture starts differs by page and by timing, and one Next.js site reported about 50 releases for the same deploys before this rule.
3. If no file has such a name, the earlier rule: every same-origin `script[src]` and `link[rel=stylesheet]` pathname.

The result is always `auto-` and a short hash, so a build id or file name never leaves the browser as it is. Bundlers that fingerprint filenames (Vite, Angular, Astro, SvelteKit, webpack) change it on each deploy when their entry file name carries the hash. Sites whose entry files have no hash report a constant value or `unversioned`; set `data-release` for those. Check the value on a recording: it should be the same across the site's pages and change when you deploy.

### Language and variants

Each segment tells the collector the page language and, when it can, the experiment variant. The site labels nothing; the collector decides the page group (`docs/API.md`, Page groups).

- `segment.locale`: the primary tag of `document.documentElement.lang`, lower case, so `es-MX` is `es`. It is read when the segment starts. Omitted when the page sets no `lang` or sets something that is not a language tag. A path prefix such as `/es` takes precedence in the collector. A language switch that does not change the address does not start a new page view and does not stop recording; the page view keeps the language it started with. A switch that changes the address (a router push to `/es`) starts a new page view that reads the new `lang`.
- `segment.experimentParam`: `{name, value}` of the first experiment parameter in the page URL, sent only when the recorded path has no query string (`captureQueryStrings` off), so the query is not recorded but the variant still groups. The names are `experience`, `variant`, `ab`, `exp`, `_vwo`, `optimizely` and the project's `variantParams`; `utm_content` is never one. The collector sets `segment.variant` from it only when the site has not set a variant.

| Preset | `experimentParam` |
| --- | --- |
| `full` | Name and value as written, trimmed to 100 characters |
| `balanced` | Value through the balanced redaction |
| `strict` | Allow-listed names only, with the value replaced by a hash (`h` and 8 hex digits) |

The collector applies the preset again, so a modified tracker cannot send more than the project allows.

### Clicks

Every pointer click records:

- `x`, `y`: viewport position from 0 to 1, for replay overlays;
- `pageX`, `pageY`: document position in CSS pixels including scroll, with `docWidth` and `docHeight` at that moment, for full-page heatmaps;
- `selector`: a stable selector for element-level counts. It uses `data-hoot-id` when present, otherwise an element `id` without a run of three or more digits, otherwise a strict-safe `aria-label` (`[aria-label="Menu"]`, only when the label passes the strict label rules below and the element is outside private, masked and blocked regions), otherwise a `tag:nth-of-type` path of up to six levels. `>>` marks an open shadow root boundary. Other attribute values and text are never included;
- `target`: the nearest `data-hoot-id`, or the tag name;
- `nodeId`, `offsetX`, `offsetY`: the rrweb node and the position within it, when measurable;
- `label`: a short readable name for the clicked element, such as "Buy now", so heatmaps and findings can show it next to the selector. See Element labels below.

A click on an icon or text inside a link, button or other control is attributed to that control. Clicks inside custom elements and open shadow roots are recorded in every preset. Keyboard activations and script-generated clicks have no pointer position and are not recorded as clicks.

Click kinds, decided one second after the click:

- `error-click` (a misfire): an uncaught script error or unhandled promise rejection followed within one second. Error messages are never read.
- `rage-click` (a pile-up): three clicks within one second, within 30 px, on the same element. A pile-up is recorded once, on the click that completes it. The clicks before and after it stay plain clicks, so one pile-up is never also several no-shows. Before this rule (trackers released before the fix) every click from the third onward was a `rage-click` and the first two could also be no-shows, so older recordings can show inflated counts. Stored counters are not recomputed.
- `dead-click` (a no-show): a link, button or similar control produced no DOM change, scroll, input, focus change or navigation within one second. The second starts at the press (`pointerdown`) when the click follows one, so a menu a touch handler opens on press or `touchend` counts as a response, and a change in the same millisecond as the click counts too. A click recorded before its second ended (an explicit `flush()` or a viewport class change) is never a no-show. Links opening new tabs, downloads, `mailto:` and `tel:` links, labels, selects and native pickers are excluded, as are toggles whose `aria-expanded`, `aria-pressed`, `aria-checked` or `<details>` state changed.

Snags are observed behavior on the page.

### Clicks in private and blocked regions

When the clicked action element is inside a `[data-hoot-private]` or `blockSelectors` region (a consent banner, a private chat), the click is recorded on that element itself, never on a surrounding public ancestor, and identified only by identifiers the site authored on it, in every preset: `data-hoot-id` (the click `target` and selector), `data-hoot-name`, `data-hoot-label` or `aria-label` (the label and, when the value is strict-safe, a matching attribute selector). Its text, its other attributes, its ancestors and its position in the region are never read. A control with none of these is recorded with target `private-control`, no selector and no label, and narratives call it "a private control". Put `data-hoot-id` on the controls whose clicks you want to count.

### Element labels

Only actionable elements are named from their text: `a`, `button`, `summary`, `[role=button]`, `[role=link]` and `input` of type `button` or `submit`. Any other target, such as a paragraph or the footer, gets a label only from an authored attribute (`data-hoot-name`, `data-hoot-label`, `aria-label`, `title`, `alt`) and otherwise none, so a click on plain text never records that text. Its `data-hoot-id` or `aria-label` still shape its selector and target.

The label of a click is computed in the browser from the action target, in this order: `data-hoot-name` (or the older `data-hoot-label`), `aria-label`, the text of the `aria-labelledby` targets, `alt` for images, `title`, then the visible text with whitespace collapsed (text of descendants, using `alt` or `aria-label` of icons and images inside). It is cut to 60 characters, and only the first 60 nodes, 6 levels and about 200 characters of the subtree are read, so a click on a very large element costs the same as a click on a button.

Two further sources keep clicks readable. An element nothing names takes the `data-hoot-name` or `data-hoot-label` of its nearest ancestor that has one, up to six levels up, so a click inside a named card reads as that card; only authored values are used, never an ancestor's text, and never across an excluded region. A form field (not password, payment, one-time-code, hidden or file) is named by its authored name, `aria-label` or the text of its `<label>`, never its value or placeholder. Dashboards show a label in quotes followed by the kind of element when known, such as "Book a workshop" button.

| Preset | Label |
| --- | --- |
| `full` | As written |
| `balanced` | Through the balanced redaction (emails, phone numbers, card-like numbers, digit runs of six or more become `*`) before it is cut |
| `strict` | Only from attributes the site authored on the action element, see below |

Under every preset a label is never taken from `[data-hoot-private]` regions, from input, textarea, select and editable regions, from elements matched by the project's `maskTextSelectors` or `blockSelectors`, or from an element whose subtree contains a password, payment or one-time-code field. An input's value is never used, so a submit button made from `<input value="Buy">` has no label. Text inside those regions is skipped even when the clicked element is not. Hidden elements (`hidden`, `aria-hidden="true"`) and script and style text are skipped.

Under `strict` visible text, descendants' names and input values are never read. The label is the first of these that is safe: `data-hoot-name`, `data-hoot-label`, `aria-label`, the `aria-label` or `title` of the elements named by `aria-labelledby` (never their text), `title`, `alt`, then `data-hoot-id`. A value is safe when it has no control characters and no email address, phone number, card-like number or run of six or more digits (the balanced patterns); an unsafe value is skipped, not redacted. Values are cut to 60 characters. `data-hoot-id` is a label source only in `strict`; `data-hoot-name` (then `data-hoot-label`) is the first source in every preset. The collector cannot see which attribute a strict label came from, so it enforces what it can: at most 60 characters, single spaces only, no leading or trailing space, no control characters, and none of those personal data patterns (reason `label not allowed under strict`). A site that puts private text in an `aria-label` therefore still sends it, unless it looks like one of those patterns; keep personal data out of `aria-label`, `title` and `alt`, or mark the element `data-hoot-private`.

A label is page text, so a link whose text is a person's name records that name under `full`. Choose `balanced` or `strict`, or mark the element with `data-hoot-private`, where link text can identify someone. Aggregates keep the most recent non-empty label per selector.

### Scroll depth

Each segment reports the deepest viewport bottom reached (`maxDepth`) and the document height, attached to batches when they change.

## Page targeting

A project can limit recording to chosen page paths (Settings, Recording, Pages to record). This is a privacy safeguard: a site can record its public homepage and nothing else, even though the tracker follows client-side navigation into other routes. The config carries `targeting: {include: string[], exclude: string[]}`. When it is absent or both lists are empty, every path is recorded.

A page is recorded when it matches any `include` rule (or `include` is empty) and matches no `exclude` rule. Exclude always wins. Rules are path globs, at most 50 per list and 200 characters each, without query strings or fragments:

| Rule | Matches | Does not match |
| --- | --- | --- |
| `/` | the home page | every other path |
| `/pricing` | `/pricing`, `/pricing/` | `/pricing/plans`, `/pricing-old` |
| `/blog/*` | `/blog/news` | `/blog`, `/blog/2024/news` |
| `/docs/v*` | `/docs/v2` | `/docs/2` |
| `/app/**` | `/app`, `/app/x`, `/app/x/y` | `/application`, `/apps` |
| `/**` | every path | nothing |

`*` matches any characters within one path segment. `**` is valid only as a whole segment and matches zero or more segments. Matching is case-sensitive. Before comparing, the path loses its query and fragment, percent-encoding is decoded (`/caf%C3%A9` equals `/café`, while an encoded slash `%2F` stays inside its segment), `.` and `..` segments are resolved, repeated slashes collapse and a trailing slash is ignored. The matcher is one pure module, `packages/core/src/targeting.ts`, used by the tracker, the collector and the dashboard preview.

The tracker evaluates the real `location.pathname` on start and on every navigation (`pushState`, `replaceState`, `popstate` and the Navigation API). It also requires that the path it would send, after `pathTemplate` or redaction, matches, so the collector never receives a path it would refuse. Because a `pathTemplate` replaces the path the collector sees, write rules that cover the template when you use one.

What the tracker does on a page that is not recorded:

- **Capture stops inside the navigation call.** The check runs synchronously from the `pushState`, `replaceState` and `popstate` hooks, before the client router can render the new route. The recorder is stopped first, which discards DOM changes it had not yet delivered, so no snapshot or mutation of the excluded route is created. Every event is also checked against the current URL as it is emitted, as a backstop.
- **The previous page is sealed, not lost.** Clicks, scroll depth, pointer movement and events captured on the last recorded page are sealed and sent as a normal final batch for that page.
- **Nothing is sent for the excluded page.** No snapshot, mutation, click, scroll, pointer or config-only data is queued or sent, and no session is created if the visit starts there. The session's idle timer is not extended while the visitor is on an excluded page.
- **Recording resumes on return.** When the visitor navigates to a recorded path, the tracker waits for the router to render, as for any navigation, then starts a new segment with a fresh snapshot and the next `pageIndex`. `tracker.status()` reports `page-excluded` while paused.

Limits to know:

- Rules decide pages, not links. A recorded page still shows the `href` of links it contains (without query strings or fragments), so a link to `/private/area` on the home page is part of the home page's recording. The destination's content is never recorded.
- The hook runs when the URL changes. A router that renders the next route in an earlier task than the one that updates the URL would show that route to the recorder first. The tested sample routers update the URL first or in the same task; this is not verified for every framework.
- A page that loads while the config is unavailable uses the fail-safe config, which has no targeting. The collector still refuses excluded paths (`path_not_targeted`, see `docs/API.md`), so they are not stored, but the browser would hold the data in memory until refused.
- A change to the rules reaches a visitor's next page load, usually within about 90 seconds; a rule that excludes more pages applies to the page that is loading (see the propagation note in `docs/API.md`).

Compat coverage: `tests/compat` has a fixture SPA with routes `/`, `/private/area` and `/about`, targeting `include: ["/"]`, and asserts that no request contains the excluded path or its planted text and that returning to `/` starts a second segment. It passes in chromium, firefox and webkit.

## Sessions and sampling

Once capture is permitted, a random session ID, last activity time, page counter and sampling decision are stored in `sessionStorage` under `hootlens:session:<projectId>`. Page loads in the same tab within 30 minutes of activity continue the session with an increasing `pageIndex`; client-side navigations increment it too. Opening a link in a new tab copies `sessionStorage`, so both tabs continue the same session.

`sampleRate` from the project config is applied once per session and persisted with it, so a visitor is either recorded for the whole session or not at all.

The referrer is recorded as an origin only, and only when it is a different site.

### Search metadata

Each segment also carries fields the dashboard and API can filter recordings by (`docs/API.md`). They are decided once per session and repeated on every page view of it.

| Field | Source | `full` | `balanced` | `strict` |
| --- | --- | --- | --- | --- |
| `referrer` | `document.referrer`, origin only | Sent | Sent | Sent |
| `utm` | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `utm_source_platform`, `utm_creative_format`, `utm_marketing_tactic` of the landing URL, first page view of the session only, trimmed, at most 100 characters each | As written | Through the balanced redaction | Omitted unless `captureQueryStrings` is on; then redacted like balanced |
| `adNetwork` | The NAME of the ad network whose click ID parameter was on the landing URL: `gclid`, `gbraid`, `wbraid`, `dclid` give `google_ads`; `fbclid` gives `meta_click`; `msclkid` `microsoft_ads`; `ttclid` `tiktok_ads`; `li_fat_id` `linkedin_ads`; `twclid` `x_ads`; `epik` `pinterest_ads`; `ScCid` `snapchat_ads`; `yclid` `yandex_ads`. The click ID value is never read into the payload, hashed or sent. Landing page view only, repeated on the session's page views | Sent | Sent | Omitted unless `captureQueryStrings` is on (same rule as `utm`) |
| `inApp` | `facebook`, `instagram`, `messenger`, `tiktok`, `linkedin`, `snapchat`, `pinterest`, `x`, `wechat` or `line` when the user agent shows that app's in-app browser; omitted for ordinary browsers | Sent | Sent | Sent |
| `browser` | `chrome`, `safari`, `firefox`, `edge` or `other`, from `navigator.userAgentData` when present, otherwise a small user agent parse | Sent | Sent | Sent |
| `os` | `windows`, `macos`, `ios`, `android`, `linux` or `other`, same sources | Sent | Sent | Sent |
| `isNewVisitor` | `true` when the first-party `localStorage` flag `hootlens:seen` was absent | Sent | Sent | Omitted |

Only the coarse browser and OS names and the in-app browser's name are sent; the raw user agent string never leaves the browser. Click ID parameters (the twelve listed above) are also removed from the recorded page path and from recorded link and resource URLs whenever `captureQueryStrings` keeps a query string, so an ID never reaches Hoot Lens through the path either. `fbclid` is added to links on Facebook and Instagram whether the link is an ad or an ordinary share, so `meta_click` is not proof of an ad; `epik` and `li_fat_id` can also appear on organic traffic, and `gclid` marks Google Ads clicks of every kind. Some apps (X on many devices, for example) open links in the system browser and cannot be detected. The collector, not the tracker, adds the visitor's country and region from the request address and derives the channel; see docs/FAQ.md. A session restarted after 30 idle minutes during client-side navigation gets no campaign data, because its page is not a landing page. Campaign parameters are marketing labels, but a link can put anything there, so the redaction patterns apply in `balanced` and the collector rejects unredacted personal data. The hosted collector checks the same rules for every batch.

`hootlens:seen` is the visitor flag. It is written (value `1`, inside try/catch) only for a visitor whose session is recorded, never under `strict`, and never before consent allows capture; `setConsent(false)` removes it. If storage is blocked, `isNewVisitor` is omitted rather than guessed. The campaign parameters and the visitor flag are also kept in the session's `sessionStorage` entry so page 2 can repeat them.

The project config is kept in `sessionStorage` (for at most 30 minutes) only so a page can start without waiting. Every page load asks the server for the current config and uses the stored copy only until it arrives. A stricter result (page rules, privacy preset or selectors, required consent) applies at once, restarting the recording under the stricter policy if one was already running. A looser result is stored and applies from the next page load. Nothing is written to cookies or IndexedDB. The `localStorage` entries are the `hootlens:seen` visitor flag described above, the visitor's consent choice `hootlens:consent:<projectId>`, the notice flag `hootlens:notice` and the opt-in `hootlens:debug`.

## Automated visits

The tracker reports, with each batch, a small bitmask of automation signals it observed in the page load (`auto`, an integer, omitted when zero). It adds no identifier, reads no page content and sends nothing beyond that integer in the body. The collector combines the bits with what it can see itself: the `User-Agent` and `Sec-CH-UA` headers every request already carries, which it reads only to test for headless browsers, automation tools and crawlers, and the click timing in the batch. It stores the names of the signals it found, never the user agent string, so the raw user agent is still never stored. Nothing about the viewport or device class is a signal: a small or odd window alone never counts.

| Signal | What the tracker observes | Strength |
| --- | --- | --- |
| `webdriver` | `navigator.webdriver` is `true` | Strong |
| `headless` | The user agent, or a `userAgentData` brand, contains `Headless` | Strong |
| `bot_agent` | The user agent names an automation tool (PhantomJS, Selenium, Puppeteer, Playwright, Lighthouse and similar). Declared crawlers are refused by the collector instead (see [API](API.md)) | Strong |
| `rapid_input` | 17 or more trusted clicks or taps within any 2 seconds (more than 8 a second, sustained) | Strong |
| `metronome` | 10 clicks on one exact page point with gaps that differ by 20 ms or less, each gap at most 5 s | Strong |
| `untrusted_events` | 5 or more clicks or presses with `isTrusted` false (dispatched by script). Fewer are ignored, because sites call `click()` themselves | Weak |
| `unpaired_clicks` | 5 or more trusted pointer clicks with no `pointerdown` or `touchstart` in the 2 seconds before | Weak |
| `no_pointer_movement` | 10 or more mouse clicks and no trusted pointer movement in the page load. Touch devices are not counted | Weak |
| `hidden_visit` | The page was never visible before it was left | Weak |
| `sustained_clicking` | 61 or more clicks within 30 seconds | Weak |
| `same_spot` | Collector only: 12 or more clicks in one batch on one exact point, 80% or more of the batch | Weak |

One strong signal makes a visit automated. Two or more weak signals make it suspected, which is labelled but still counted. One weak signal alone changes nothing. Keyboard and assistive activation (`detail` 0) is ignored by every pointer signal, and recording itself is unchanged: untrusted and keyboard clicks were already left out of the recorded clicks.

Thresholds were chosen against two groups of examples: a flurry of 137 clicks in 6 seconds (about 23 a second, flagged within the first second), and a person tapping one button 3 to 8 times at about 6 a second, or double tapping (never flagged). Slow, varied clicking (for example 134 clicks across 6 minutes) cannot be told from a person without other evidence and is not flagged by rate alone.

The module is `packages/tracker/src/automation.ts`, built on `@hootlens/core/automation`, and it is separate from `recorder.ts`, which only forwards five events to it (click, press, first pointer movement, page shown and page leave). It holds at most 61 timestamps and 8 points however long a visit lasts, adds a passive `touchstart` listener and a `pointermove` listener that removes itself on the first trusted movement, and costs about 3 KiB minified and 1.2 KiB gzip in `t.js`. A batch grows by about 10 bytes only when a signal has been seen.

## Mobile visits

A visit by touch is replayed from events rrweb already records, so nothing extra is captured for it: a press and release (mouse interaction types 7 and 9, with pointer type 2 on the click that follows; `strict` keeps the pointer type, which is a number), finger movement (touch move positions) and scrolling. The dashboard draws a tap as a ripple, a press held 500 ms or more without moving as a dashed ring, and a swipe (movement beyond 12 px) as a short fading trail, in place of a cursor. A press that Chrome hands to scrolling sends no release, so a swipe ends at its last movement. Positions only, no text.

The tracker adds one small custom event, `hootvv`, for what rrweb cannot see: pinch zoom and the on-screen keyboard or browser bars change the visual viewport but not the layout viewport. It holds five numbers (zoom scale, visible width and height, and the visible area's offset inside the layout viewport), is sent when they change (at most about four times a second, only while zoomed or while the visible area differs from the layout viewport, plus one event when that ends), and is rebuilt from numbers in every preset, so no text or element can ride on it. A page that is never zoomed sends none. It adds about 1.5 KB to the tracker (0.5 KB gzipped). A new page view starts without a marker, so zoom carried across a navigation is not shown.

The replay frame follows the recorded window: rrweb viewport resize events (a turn, the keyboard on browsers that resize the window, the address bar) resize it smoothly, and a device class change starts a new page view with its own viewport.

## Delivery

- Events are serialized during idle time and grouped into batches of at most 480 events and about 1 MB of JSON. A large first snapshot is sent in its own batch rather than stopping capture.
- Batches are gzip-compressed with `CompressionStream` and sent with `Content-Encoding: gzip` when the browser supports it, otherwise as JSON. The collector limits are 1 MiB compressed and 4 MiB uncompressed. If one event (normally a full snapshot) is still over the limit, that page view is skipped and `onError` reports it; capture resumes on the next page.
- Buffered data is sealed every five seconds. Failed requests retry with exponential backoff (1 to 60 seconds with jitter, honouring `Retry-After`) from an in-memory queue of at most 5 MB; the oldest batches are dropped first when the collector stays unreachable. Retries resend identical bytes with the same segment and sequence.
- A `410` or any other `4xx` except `408` and `429` stops capture for the page and reports through `onError`. That includes the permanent limits: `413` when a recording passes its own byte, event or batch cap, and `422` when a project or account cap is reached. Only `429`, the transient intake budget, is retried with backoff.
- A refused batch logs one `console.warn` per page, naming the reason, the HTTP status, the collector's code when it sent one (`origin_not_registered`, `path_not_targeted`, `recording_deleted`, `usage_limit`, `project_pending`) and `https://hootlens.com/app` for install status. The collector answers refusals with `Access-Control-Allow-Origin` for the requesting origin, so the real status is visible in the browser's network panel instead of a CORS error.
- `409` with the code `policy_changed` means the project's privacy settings changed after this page view began, so its batches were checked against the new policy. The tracker drops what it had queued, fetches the config again past the browser cache and the stored copy, and restarts capture in a new segment of the same session under the new settings. This happens at most once per page load; a second `policy_changed`, or a config that cannot be fetched, stops capture like any other refusal. Other `409` answers stop capture.
- `setConsent(false)` also removes the tracker's `sessionStorage` entries (the session ID and the cached project configuration).
- On `pagehide` and when the page becomes hidden, the tracker sends a final batch with `fetch` `keepalive` and `final: true`, within the browser's 64 KiB keepalive budget. Clicks and scroll depth go first, then as many events as fit; already sealed batches follow if they fit. Data beyond the budget can be lost when the tab closes. If the page stays open, sending continues normally, so a segment can contain more than one batch marked `final`.
- Requests omit credentials and referrers.

## Bundle

| Output | Minified | Gzip |
| --- | --- | --- |
| `t.js` (standalone, includes `@rrweb/record` 2.1.7) | 147 KiB | 47 KiB |
| `index.js` (ESM, `@rrweb/record` loaded on demand) | 38 KiB | 13 KiB |

The previous build bundled the full `rrweb` package including the replayer: 274 KiB minified, 87 KiB gzip. Third-party license notices are included at the top of `t.js` and in `packages/tracker/licenses`.

## Lint and types

Two npm packages teach the conventions in this document to the tools developers and coding agents already run. Neither is part of the tracker, and neither sends anything anywhere.

`@hootlens/types` declares `window.hootlens` (the command queue, as a union of every command the tag runs), `window.Hootlens`, the status and diagnostics objects and the `data-hoot-*` attributes for React, Vue and Svelte. A mistyped command or a `['consent', 'yes']` fails to compile. `packages/types/test` and `tests/types.test.ts` check the types against `packages/tracker/src` and this document, so a command added to the tracker fails CI until it is typed.

```ts
import '@hootlens/types';
import '@hootlens/types/react';

window.hootlens?.push(['consent', true]);
```

`eslint-plugin-hootlens` adds six rules for ESLint 9 flat config (and a legacy config). Each message explains the effect on Hoot Lens data and links back to this document, so an agent that runs lint reads the convention from the output.

```js
import hootlens from 'eslint-plugin-hootlens';

export default [hootlens.configs.recommended];
```

| Rule | Level | Catches | Sections it points to |
| --- | --- | --- | --- |
| `hootlens/stable-ids` | warn | Interactive elements without a kebab-case `data-hoot-id`, or with one that looks generated. | [Clicks](#clicks), [Element labels](#element-labels) |
| `hootlens/no-page-version-effect` | error | `['pageVersion', ...]` or `['variant', ...]` pushed from an effect or from render. | [Language and variants](#language-and-variants), [Pages, segments and heatmaps](#pages-segments-and-heatmaps) |
| `hootlens/private-fields` | warn, only with `preset: "full"` | A form or region with password, payment, ID or one-time-code fields outside `data-hoot-private`. | [Privacy presets](#privacy-presets) |
| `hootlens/dynamic-regions` | warn | Random, clock or carousel content without `data-hoot-dynamic`. | [`data-hoot-dynamic`](#data-hoot-dynamic) |
| `hootlens/consent-false-only-on-refusal` | warn | `['consent', false]` outside a handler that looks like a visitor's refusal. | [Commands](#commands), [Stop, start and consent](#stop-start-and-consent) |
| `hootlens/no-tracker-in-private-routes` | warn | The tag or `init` under `app/`, `admin/`, `account/`, `dashboard/` or `portal/` routes. | [Page targeting](#page-targeting) |

The rules read one file at a time. They do not follow imports and they do not know what your components render, so they miss a problem that spans files and can flag code that is right in context. They are not a privacy control: the tracker enforces the privacy floor in the browser whatever the lint result. `private-fields` is quiet by default because `balanced` and `strict` already redact or replace the text around a field; under `full`, that text is recorded as written, which is what the rule asks you to mark `data-hoot-private`.

## Not yet verified

Capture has been checked only with synthetic pages in headless Chrome. Firefox and Safari behaviour, keepalive delivery on real tab closes and very large documents still need verification on real sites. Framework installs are checked by the compatibility matrix in headless Chromium (see [FRAMEWORKS.md](FRAMEWORKS.md) for what is and is not covered).
