Install & SDK

One line of HTML.
Live in a minute.

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

This is the whole install.

index.html — before
<!-- Observer — cookie-less, self-hosted -->
<script async src="https://sa.yourdomain.com/v.js" data-site="obs_XXXX"></script>
Async and first-party

Loaded from your own subdomain — it survives ad blockers, keeps every endpoint same-site and never touches a third-party network.

Tiny by budget

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.

Verified by the wizard

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

Three steps. A green check at the end.

1
Name + domains

Site name, primary URL and any extra domains — staging, subdomains, alternates. Duplicate-domain registration across sites is blocked (HTTP 409).

2
Privacy tier

T0 strict (default, no banner), T1 session storage, T2 consented (unlocks replay), or T3 kill-switch. The collector enforces it server-side.

3
Copy the snippet

Paste, browse, watch the wizard verify the first page view live and flip green with a running view count.

Content-Security-Policy

Two directives. That's the integration.

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 snippet is locked to your domains.

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.

Origin allowlist

Collector checks the request origin against the site's registered domains on every batch.

Signed site configs

Per-site configuration (tier, policy, domains, sampling) is Ed25519-signed — the SDK cannot forge an upgrade.

Abuse caps

Rate limits, payload caps and event-id dedup protect the collector from spam and replays.

PII scrubbing

Secrets and identities never reach the disk.

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

Mark up once, get better data forever.

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 / classEffect
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-interactiveMarks interactive targets so dead-click detection knows what should be clickable
data-obs-ignoreExcludes 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

What the snippet captures — exactly.

No configuration required. Every row is scrubbed, capped and deduplicated at the collector.

EventTriggerData
page_viewpage load / SPA route changepath (scrubbed), title (hashed), referrer origin, UTM
page_exittab hide / pagehide / SPA exitengaged_ms (active-visible), scroll_max, exit_type
heartbeatevery 15 s while visibleengaged_ms cumulative
clickuser clickx%·y% of page (doc-normalized), element (hashed), rage / dead flags
scrolldepth bucket crossingdepth (25 / 50 / 75 / 100)
web_vitalLCP / INP / CLS / FCP / TTFB callbackname, value, rating, attribution (element / target)
errorJS error / unhandled rejectionerror class only — no stack, no message text
outboundlink click leaving the domaintarget origin
downloadfile link clickfile extension
formfield focus / blurform hash, field hash, kind (focus / filled / skipped), ms
goalserver-side (s2s)goal_id
customobserver('event', name, props)name + typed props (scrubbed, ≤ 10, per-site allowlist)
survey_responsesurvey widget submitsurvey_id, value, type
optoutobserver('optOut')signals the SDK to stop and clear
window.observer — public API
// 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

A small public API — nothing hidden.

Everything is explicit

Custom events, consent, opt-in/out, surveys, debug — one function namespace, documented behavior, no surprise beacons.

Opt-out is real

observer('optOut') stops the SDK and clears any state; DNT and GPC headers do the same automatically.

Custom events are gated

A strict schema: at most 10 properties, scrubbed, per-site allowlist. The collector rejects anything else.

Paste one line.
Watch the green check.

The wizard verifies your install live — first page view, then a running count. No guesswork, ever.