Origin allowlist
Collector checks the request origin against the site's registered domains on every batch.
Install & SDK
Async, no build step, no inline code, CSP-friendly, served first-party from your own subdomain. The core tracker is ≤ 10 KB gzipped and initializes in ≤ 5 ms at p95 — Core Web Vitals and the recorder lazy-load only when policy allows.
The snippet
<!-- Observer — cookie-less, self-hosted --> <script async src="https://sa.yourdomain.com/v.js" data-site="obs_XXXX"></script>
Loaded from your own subdomain — it survives ad blockers, keeps every endpoint same-site and never touches a third-party network.
Core tracker ≤ 10 KB gzipped: page lifecycle, engaged time, heatmaps, clicks. The official web-vitals build and the rrweb recorder lazy-load only when your tier's policy allows.
The add-website wizard polls until the first page view lands and flips green with a running view count — you never wonder whether it works.
Add-website wizard
Site name, primary URL and any extra domains — staging, subdomains, alternates. Duplicate-domain registration across sites is blocked (HTTP 409).
T0 strict (default, no banner), T1 session storage, T2 consented (unlocks replay), or T3 kill-switch. The collector enforces it server-side.
Paste, browse, watch the wizard verify the first page view live and flip green with a running view count.
Content-Security-Policy
script-src 'self' https://sa.yourdomain.com; connect-src 'self' https://sa.yourdomain.com;
No unsafe-inline, no blob:, no data: — the tracker is a plain async script and speaks only to your collector. Strict CSP sites adopt Observer without weakening a single directive.
Anti-spoofing
The same code pasted onto any other site is refused at the collector with HTTP 403. Your site ID is not a free-for-all token — extend the allowlist any time for subdomains, staging or alternate domains.
Collector checks the request origin against the site's registered domains on every batch.
Per-site configuration (tier, policy, domains, sampling) is Ed25519-signed — the SDK cannot forge an upgrade.
Rate limits, payload caps and event-id dedup protect the collector from spam and replays.
PII scrubbing
URLs and titles are scrubbed server-side before anything is stored: emails become [email], card-length digit runs become [num], API-key-shaped strings become [key]. Page titles are hashed. Error events carry the error class only — no stack, no message text.
A live-HTTP test in the suite feeds known PII through the collector and asserts the scrubbed output end-to-end.
DOM contract for publishers
A tiny set of attributes and classes connects your markup to the analytics — element ranking, form analytics, dead-click detection and recorder masking all read it.
| Attribute / class | Effect |
|---|---|
| data-obs-id="hero-cta" | Stable element id for heatmap element ranking — survives redesigns better than hashed selectors |
| data-obs-form="checkout" | Names a form for form drop-off analytics |
| data-obs-interactive | Marks interactive targets so dead-click detection knows what should be clickable |
| data-obs-ignore | Excludes an element from heatmaps and form analytics |
| class="obs-mask" | Recorder masks this element's text at source |
| class="obs-block" | Recorder excludes this element entirely |
| class="obs-ignore" | Recorder ignores events inside this subtree |
Auto-tracked events
No configuration required. Every row is scrubbed, capped and deduplicated at the collector.
| Event | Trigger | Data |
|---|---|---|
| page_view | page load / SPA route change | path (scrubbed), title (hashed), referrer origin, UTM |
| page_exit | tab hide / pagehide / SPA exit | engaged_ms (active-visible), scroll_max, exit_type |
| heartbeat | every 15 s while visible | engaged_ms cumulative |
| click | user click | x%·y% of page (doc-normalized), element (hashed), rage / dead flags |
| scroll | depth bucket crossing | depth (25 / 50 / 75 / 100) |
| web_vital | LCP / INP / CLS / FCP / TTFB callback | name, value, rating, attribution (element / target) |
| error | JS error / unhandled rejection | error class only — no stack, no message text |
| outbound | link click leaving the domain | target origin |
| download | file link click | file extension |
| form | field focus / blur | form hash, field hash, kind (focus / filled / skipped), ms |
| goal | server-side (s2s) | goal_id |
| custom | observer('event', name, props) | name + typed props (scrubbed, ≤ 10, per-site allowlist) |
| survey_response | survey widget submit | survey_id, value, type |
| optout | observer('optOut') | signals the SDK to stop and clear |
// custom events (typed, scrubbed) observer('event', 'signup_click', { plan: 'pro' }); // consent bridge (T1/T2 sites) observer('consent', 'granted'); // explicit control observer('optIn'); observer('optOut'); // silences + clears // trigger a survey widget observer('survey', { question: 'How was checkout?', type: 'nps' }); // debug logging observer.debug();
window.observer
Custom events, consent, opt-in/out, surveys, debug — one function namespace, documented behavior, no surprise beacons.
observer('optOut') stops the SDK and clears any state; DNT and GPC headers do the same automatically.
A strict schema: at most 10 properties, scrubbed, per-site allowlist. The collector rejects anything else.
The wizard verifies your install live — first page view, then a running count. No guesswork, ever.