@atrix.dev/analytics-web
v0.1.1
Published
Atrix Analytics for the browser: events, pageviews, identity, consent. Tiny, cookieless-capable, never throws.
Readme
@atrix.dev/analytics-web
Atrix Analytics for the browser. It is 7.7 KB gzipped with its core included. It never throws, it does nothing when it isn't configured, and it can run without cookies.
bun add @atrix.dev/analytics-web # or npm i / pnpm addimport * as atrix from "@atrix.dev/analytics-web";
atrix.init("atx_pk_eu_production_XXXXXXXXXXXXXXXXXXXXXX"); // region comes from the key → eu.i.analytics.atrix.dev
atrix.track("checkout_completed", { amount: 1999, currency: "AED", booking_id: "bk_42" }); // money: integer minor units
atrix.identify(user.id, { plan: "pro" }); // your user id (never an email or phone number)
atrix.group("company", "acme", { name: "Acme" });
atrix.register({ environment: "production" }); // stamped on every later event, persisted
atrix.reset(); // on sign-out$pageview is recorded automatically when the page loads and on every SPA navigation (pushState,
replaceState and popstate). Call atrix.page() yourself if you turn that off.
API
| Call | Notes |
| --- | --- |
| init(publicKey, options?) → AtrixWeb | Starts the default client. Calling it again replaces the client. On the server (SSR) and with an invalid key it is a no-op. |
| createAtrix(publicKey, options?) | A separate client, for several projects on one page. |
| track(event, properties?, { $set?, $set_once?, timestamp? }?) | Event names starting with $ are reserved for system events. |
| page(properties?) | A manual $pageview. |
| identify(distinctId, $set?, $set_once?) | One-way. Once identified, a different id is ignored until reset(). Junk ids (guest, null, anonymous, …) are refused. There is no alias(): identified people are never merged on the client. |
| setRevenueCustomer(customerId, { provider? }?) | Links a billing customer id (Stripe cus_…, RevenueCat app_user_id, …) to the current user by sending $revenue_customer, so server-side revenue webhooks are attributed to them. provider is stripe, revenuecat, app_store or google_play; an unknown value is dropped. An empty id or one over 200 characters is ignored. |
| reset() | New anonymous id and session; clears super-properties and groups. |
| group(type, key, properties?) | Up to 5 group types. Later events carry groups. |
| register(props) / registerOnce(props) / unregister(key) | Persisted super-properties. |
| setConsent("granted" \| "pending" \| "denied"), optIn(), optOut(), getConsent() | See Privacy below. |
| flush(), shutdown() | Both return promises that never reject. |
| getDistinctId(), getInstance() | |
The instance returned by init() has the same methods, plus snapshot() for diagnostics and destroy(),
which stops the client and removes its listeners without flushing.
Feature flags
atrix.isEnabled("new-checkout"); // false until flags load (lazily, on first use)
atrix.getVariant("pricing"); // "test" | "control" | null
atrix.getPayload("pricing");
atrix.onFlags((flags) => render(flags)); // now (if known) and after every load
init(KEY, { bootstrap: { flags: { "new-checkout": true } }, preloadFlags: true });Flags are cached in the same storage as identity, reload on identify/group/reset, and each read sends one
$feature_flag_called per flag and value per session (sendFeatureFlagEvents: false turns that off).
Options
| Option | Default | |
| --- | --- | --- |
| apiHost | https://<region>.i.analytics.atrix.dev | A first-party proxy, e.g. https://example.com/_atx. The SDK posts to {apiHost}/e. |
| persistence | "localStorage" | "cookie" keeps identity in a first-party cookie; set cookieDomain: ".example.com" to share it across subdomains. The queue stays in localStorage either way. "memory" keeps everything in memory only. |
| cookieless | false | Uses no storage at all and tags events consent: "cookieless". |
| consent | "granted" | The starting state when the visitor hasn't decided. A stored decision takes precedence. |
| capturePageview | true | $pageview on load and on SPA navigation. |
| autocapture | false | true records clicks on links and buttons as $autocapture (tag and link path only). Use { captureText: true } to include element text. |
| beforeSend(event) | | Return the event (edited if you like), or null to drop it. If the hook throws, the event is dropped. |
| propertyDenylist | [] | Keys removed from properties, $set and $set_once of every event, after beforeSend. |
| superProperties | {} | Stamped on every event for the life of the page. Not persisted; register() is. |
| flushAt / flushIntervalMs | 20 / 30 000 | Batching. Everything queued is also sent with sendBeacon when the page is hidden. |
| maxQueueSize / maxQueueBytes | 1000 / 5 MB | When full, the oldest events are dropped and reported in $dropped_events. |
| requestTimeoutMs, sessionIdleTimeoutMs | 10 s, 30 min | |
| compression | true | Request bodies are gzipped. |
| debug | false | Logs problems to the console. |
Privacy defaults
- No IP addresses are stored. Capture looks up the country from the IP and then discards it.
- URLs are cleaned. The values of query parameters named like
token,secret,password,auth,key,code,session,sig,email,phoneorotpare replaced with[redacted]before any URL leaves the page. UTM parameters are never redacted. - Autocapture is off. When you turn it on, it records no element text unless you ask for it, and
anything inside
[data-atx-no-capture]is skipped. Form inputs are never read. - Consent.
pendingkeeps events in memory, sends nothing and writes nothing to storage.grantedsends the held events, taggedconsent: "pending", and starts persisting.deniednever touches the network and deletes the stored identity and queue; it keeps only the decision. - Cookieless mode doesn't read or write cookies, localStorage or sessionStorage.
- On the first visit, first-touch attribution is sent once as
$set_once:$initial_utm_*,$initial_referrerand$initial_current_url. It is not stored on the device.
Delivery
Batches are gzipped and sent as text/plain with ?compression=gzip-js. That makes them a CORS "simple
request", so there is no preflight round trip. When the page is hidden (visibilitychange or pagehide),
the queue goes out through navigator.sendBeacon. Events that fail to send wait in localStorage and are
retried with backoff, including after a reload. Every event has a UUIDv7 that capture uses to drop
duplicates, so a retry never counts twice.
Migrating an existing wrapper
PlayO's PostHog wrapper becomes:
atrix.init(KEY, { superProperties: { environment: ENV } });
export const track = (event: AnalyticsEvent, props?: Props) => atrix.track(event, props);
export const identifyPlayer = (p: { id: string; city?: string | null; level?: number | null }) =>
atrix.identify(p.id, { city: p.city, level: p.level });
export const resetAnalytics = () => atrix.reset();The wrapper no longer needs clean() or a try/catch around each call: undefined values are dropped and
nothing throws. For RentGari's GA4 wrapper, keep the per-event helpers and send integer paisa with
currency: "PKR" instead of converting to rupees. Its consent opt-out maps to atrix.optOut().
Session replay
atrix.init(KEY, { replay: { sampleRate: 0.5, minDurationMs: 2000, triggers: { events: ["checkout_started"] } } });Off by default. The recorder (rrweb) is a separate chunk (~27 KB gz) that is downloaded only when a session is
actually recorded, and only while consent is granted: pending records nothing, denied stops and drops
everything buffered. Privacy defaults: every input value is masked (password inputs always, even inside
data-atrix-unmask), data-atrix-mask masks a subtree's text, data-atrix-unmask reveals one,
data-atrix-block replaces a subtree with an empty box, canvas and iframes are never recorded, and requests
are recorded as URL (secrets redacted), method, status and timing only — never headers or bodies. Console
errors (300 chars) and uncaught exceptions are recorded for the player's timeline. Chunks of ~5 s go to
POST /r with retry; sessions the project samples out, and sessions past 4 h, stop recording.
startReplay() / stopReplay() switch it at runtime.
Development
bun test # unit tests (happy-dom) + the full sdks/spec conformance matrix
bun run size # builds dist/ and fails if core+web is over 12 KB gz or the replay chunk over 30 KB gz
bun run conformance # the conformance CLI on its ownMIT licensed.
