unajs-analytics-client
v1.0.0
Published
Offline-first analytics client SDK for web & Electron (React) — central autocapture, durable queue, background sync.
Readme
unajs-analytics-client
Offline-first analytics client for web and Electron renderers (React UI layer). Pairs with the analytics-ingest worker (the ingest/write plane).
- Central autocapture — one listener at the top captures clicks (buttons, links,
[role], data-attrs), SPA pageviews, and a breadcrumb trail. You don't instrument every element. - Offline-first — events are timestamped and written to a durable IndexedDB queue (with localStorage / in-memory fallbacks). They keep accumulating while offline and sync automatically when connectivity returns.
- Invisible sync — delivery happens in the background with batching, exponential backoff, and unload-safe
keepaliveflushes. No toasts, no spinners, nothing in the user's way. - Fail-safe — every public method catches its own errors. A broken network, a full disk, a bad key, or a poisoned event will never throw into your UI.
- Privacy-safe defaults — input values are never captured; sensitive regions are masked; opt-out and Do-Not-Track are supported.
How it links to the ingest worker
The client posts batches to POST {host}/v1/events/batch, authenticated with a publishable key (pk_…) via Authorization: Bearer. The worker derives the appId from the key — it is never sent from the client and cannot be spoofed.
┌────────────────────┐ batch POST /v1/events/batch ┌──────────────────────┐
│ analytics-client │ Authorization: Bearer pk_… │ analytics-ingest │
│ (browser/Electron) │ ───────────────────────────────▶ │ (Cloudflare Worker) │
│ durable queue │ ◀─────────── 202 accepted ─────── │ → Queue → D1 │
└────────────────────┘ └──────────────────────┘Provision a publishable key
An analytics admin can create an app and its key in the dashboard. Trusted non-browser automation
may use the optional ADMIN_TOKEN service credential instead. Publishable keys must declare an
origin allowlist (the service rejects open publishable keys):
curl -X POST https://analytics-ingest.unajs.com/admin/apps/<APP_ID>/keys \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "type": "publishable", "allowedOrigins": "https://app.example.com" }'
# → { "key": "pk_live_…", ... }Origin allowlist must match where your UI runs. The browser/Electron sends an
Originheader automatically and the service checks it against the key's allowlist. See Electron notes for thefile:/// custom-protocol gotcha.
Install
pnpm add unajs-analytics-client
# react is a peer dependency (>=18) — already present in your appQuick start — React (web or Electron renderer)
Wrap your app once, near the root:
import { AnalyticsProvider } from "unajs-analytics-client/react";
export function Root() {
return (
<AnalyticsProvider
config={{
apiKey: "pk_live_…", // publishable key
host: "https://analytics.example.com",
}}
>
<App />
</AnalyticsProvider>
);
}That's it. Clicks and pageviews are captured automatically. Add custom events anywhere:
import { useAnalytics } from "unajs-analytics-client/react";
function CheckoutButton() {
const analytics = useAnalytics();
return (
<button onClick={() => analytics.track("checkout_completed", { plan: "pro", value: 49.99 })}>
Pay
</button>
);
}Identify the user after login, and reset on logout:
analytics.identify("user_123", { plan: "pro" });
// …later…
analytics.reset();Quick start — without React (plain TS / shared module)
import { createAnalytics } from "unajs-analytics-client";
export const analytics = createAnalytics({
apiKey: "pk_live_…",
host: "https://analytics.example.com",
});
analytics.start(); // begins capture + background sync
analytics.track("app_opened");Central autocapture
A single capture-phase listener observes the whole document — nothing is attached per element.
| Captured | Event | Notes |
|---|---|---|
| Clicks on buttons, links, [role=button\|link\|tab\|menuitem\|…], and [data-analytics*] | $autocapture ($event_type: "click") | Walks up to the nearest actionable element. |
| SPA route changes (pushState/replaceState/popstate/hashchange) | $pageview | Initial load + every client-side navigation. |
| Recent events | breadcrumbs | A rolling trail attached to each outgoing event. |
Controlling capture from your markup
<!-- Rename the captured event -->
<button data-analytics-event="cta_signup">Get started</button>
<!-- Attach custom properties (become $attr_*) -->
<button data-analytics-plan="pro" data-analytics-source="navbar">Upgrade</button>
<!-- Never capture this element or its subtree -->
<div data-analytics-no-capture> … </div>
<!-- Redact captured text (PII, card numbers, etc.) -->
<div data-analytics-mask> {{ user.fullName }} </div>Configuring autocapture
createAnalytics({
apiKey, host,
autocapture: {
clicks: true,
pageviews: true,
pageleave: true, // $pageleave with time-on-page (default on)
ignoreSelectors: [".no-track"], // skip clicks within these
captureAttributes: ["data-testid"], // record extra attributes
masking: {
maskAllText: false, // capture only structure, no text
maskTextSelectors: [".pii"],
redactionText: "[redacted]",
},
},
});
// Or turn it off entirely and track manually:
createAnalytics({ apiKey, host, autocapture: false });Input values are never read. For form fields the SDK uses aria-label / placeholder / name as a label, never the typed value.
Offline support & sync
Every event is timestamped at capture time and written to a durable queue before any network attempt:
- Backend selection (best available): IndexedDB → localStorage → in-memory. IndexedDB survives reloads and crashes and holds far more than localStorage's ~5 MB.
- Batching: events flush when the queue hits
flushAt(default 20), everyflushIntervalMs(default 10 s), and on page hide/visibility-hidden via akeepaliverequest that survives teardown. - Offline: while
navigator.onLineis false the SDK keeps buffering and makes no network attempts. The moment theonlineevent fires, it flushes and resets any backoff. - Delivery is at-least-once: events are removed from the queue only after a confirmed
2xx. A crash mid-flush re-sends rather than loses. Each event carries a client-generated$insert_idso the server can de-duplicate. - Failure handling:
- Network error /
429/5xx→ retry with exponential backoff + jitter (honoursRetry-After). Events are preserved. 401/403(bad key / disallowed origin) and404/405(misconfiguredhost/ endpoint) → queue is preserved, sending pauses and retries slowly so a fixed config recovers without data loss. A single error is logged.400/413/422(bad payload) → the batch is bisected to find and drop only the offending event(s); the good ones are still delivered.
- Network error /
- Bounded: the queue is capped (
maxQueueEvents, default 10 000). On overflow the oldest events are dropped and the count surfaces on the next event as$dropped_events.
Events captured offline are tagged $captured_offline: true.
All of this happens silently — there is no UI surface for sync state by design.
API
createAnalytics(config) / useAnalytics() return an object with:
| Method | Description |
|---|---|
| track(event, properties?) | Record a custom event. |
| identify(distinctId, properties?) | Bind subsequent events to a known user; emits $identify. |
| page(name?, properties?) | Record a pageview manually. |
| reset() | Clear identity + session (logout). Queued events are still delivered. |
| captureException(error, properties?) | Record a structured $exception. |
| register(properties) / unregister(key) | Persisted "super" properties merged into every event. |
| flush() | Force a flush attempt (resolves when it settles; never rejects). |
| getDistinctId() / getSessionId() | Current identity / session. |
| optOut() / optIn() / hasOptedOut() | Privacy controls. optOut clears the buffer. |
| start() / shutdown() | Lifecycle (managed for you by AnalyticsProvider). |
React hooks
import { useAnalytics, useOptionalAnalytics, usePageView, useTrackEvent } from "unajs-analytics-client/react";
usePageView(pathname, undefined, [pathname]); // manual route tracking (e.g. React Router)
const track = useTrackEvent(); // stable track callbackConfiguration reference
| Option | Default | Description |
|---|---|---|
| apiKey (required) | — | Publishable (pk_live_…) or secret key. |
| host (required) | — | Service base URL. |
| instanceName | "default" | Namespaces storage; set per-instance if you run more than one. |
| flushAt | 20 | Flush when this many events are queued. |
| flushIntervalMs | 10000 | Periodic flush cadence. |
| maxBatchSize | 100 | Events per request (service hard cap 250). |
| maxQueueEvents | 10000 | Durable queue cap; oldest dropped on overflow. |
| requestTimeoutMs | 15000 | Per-request timeout. |
| minRetryBackoffMs / maxRetryBackoffMs | 1000 / 60000 | Backoff bounds. |
| sessionTimeoutMs | 1800000 | Inactivity window before a new session. |
| autocapture | true | false to disable, or an options object. |
| breadcrumbs | true | false, or { maxBreadcrumbs, perEvent, includeProperties }. |
| defaultProperties | {} | Merged into every event. |
| respectDnt | false | Treat Do-Not-Track as opted out. |
| disabled | false | Start fully inert. |
| beforeSend | — | (event) => event \| null to mutate/drop before queueing. |
| transport / queueStore / kvStore / networkMonitor | auto | Inject custom adapters (testing, custom backends). |
| debug / onLog | false | Internal diagnostics (console or a custom sink). |
Electron notes
The renderer process behaves like a browser: autocapture, IndexedDB, and navigator.onLine all work. The one thing to get right is the Origin the renderer sends, because the publishable key's allowlist is checked against it.
- electron-vite dev: the renderer is served from
http://localhost:<port>→ allowlist that origin. - Production loading
file://…/index.html: cross-originfetchsendsOrigin: null. Either:- add the literal string
nulltoallowedOrigins(simple, but matches anyfile://page), or - register a custom privileged scheme (e.g.
app://) or serve the renderer overhttp://localhost, and allowlist that origin (tighter).
- add the literal string
Put the publishable key in your renderer build config (it's not a secret — it only allows ingestion from allowlisted origins). Keep sk_live_… secret keys out of the client.
Privacy
- Input/textarea/
contenteditablevalues are never captured. passwordfields,[data-analytics-mask]regions, andmaskTextSelectorsare redacted.optOut()stops tracking and clears the local buffer;respectDnthonours the browser signal.- URLs and hrefs are captured as-is — use
beforeSendto scrub query-string tokens if your app puts secrets in URLs.
Consent (optional)
An optional, layout-agnostic consent UI ships under the /consent subpath — it
is only bundled if you import it. It renders the notice + accept/reject actions; you
decide how to present it (bar, modal, popover — the SDK never positions anything).
Wrap your app (inside AnalyticsProvider) with <ConsentProvider>, then drop in the
ready-made <ConsentBanner> and style/position it yourself:
import { AnalyticsProvider } from "unajs-analytics-client/react";
import { ConsentProvider, ConsentBanner } from "unajs-analytics-client/consent";
<AnalyticsProvider config={{ apiKey: "pk_live_…", host: "https://…" }}>
<ConsentProvider mode="opt-in">
<App />
{/* You own the positioning/styling of this wrapper */}
<div className="cookie-bar">
<ConsentBanner policyUrl="/privacy" />
</div>
</ConsentProvider>
</AnalyticsProvider>mode—"opt-in"(default) captures nothing until the user accepts (GDPR-style);"opt-out"captures by default and the banner can opt them out. Internally this just drives the SDK'soptIn()/optOut().policyVersion— bump it to invalidate prior decisions and re-prompt.- The decision is persisted (localStorage); the banner shows only until a choice is
made.
useConsent()exposesreset()for a "manage cookies" link.
Full control — build any UI with the render-prop or the hook; both accept() /
deny() map straight to consent:
<ConsentBanner>
{({ accept, deny }) => (
<MyModal>
<button onClick={accept}>Allow</button>
<button onClick={deny}>No thanks</button>
</MyModal>
)}
</ConsentBanner>
// or headless:
import { useConsent } from "unajs-analytics-client/consent";
const { needsDecision, accept, deny, status } = useConsent();| Export | What it is |
|---|---|
| ConsentProvider | Bridges the stored decision to optIn()/optOut(). Render inside AnalyticsProvider. |
| ConsentBanner | Ready-made notice (message + actions). No positioning/styling; render-prop for custom markup. |
| useConsent / useOptionalConsent | Headless access to { status, needsDecision, accept, deny, reset }. |
Development
pnpm install
pnpm build # tsup → ESM + CJS + .d.ts in dist/
pnpm test # vitest (jsdom)
pnpm check-types # tsc --noEmitArchitecture
src/
index.ts Public core entry
types.ts Public type surface
constants.ts Defaults, SDK event names, storage keys
core/
client.ts AnalyticsClient + createAnalytics (orchestrator)
config.ts Config validation + defaults
sync.ts Background delivery: batching, backoff, bisection
queue.ts Durable queue coordinator (cap, overflow, size)
transport.ts fetch transport (keepalive, timeout, classification)
identity.ts Anonymous → known distinctId
session.ts Rolling session id with inactivity timeout
breadcrumbs.ts Rolling breadcrumb buffer
superProperties.ts Persisted super properties
context.ts Page/lib context enrichment
network.ts online/offline monitor
logger.ts / util.ts Diagnostics + helpers (uuid, mutex, backoff)
storage/
indexeddb.ts Durable queue (preferred)
localStorage.ts KV + queue fallback
memory.ts Last-resort fallback
arrayQueue.ts Shared array-backed queue base
factory.ts Probe + select the best backend
capture/
autocapture.ts Orchestrates click + pageview capture
clicks.ts Document-level click capture
pageviews.ts SPA pageview capture (history patching)
dom.ts Element description, selectors, masking
react/
provider.tsx <AnalyticsProvider> (StrictMode-safe lifecycle)
hooks.ts useAnalytics, usePageView, …
context.ts React context