npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 keepalive flushes. 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 Origin header automatically and the service checks it against the key's allowlist. See Electron notes for the file:// / custom-protocol gotcha.


Install

pnpm add unajs-analytics-client
# react is a peer dependency (>=18) — already present in your app

Quick 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:

  1. Backend selection (best available): IndexedDB → localStorage → in-memory. IndexedDB survives reloads and crashes and holds far more than localStorage's ~5 MB.
  2. Batching: events flush when the queue hits flushAt (default 20), every flushIntervalMs (default 10 s), and on page hide/visibility-hidden via a keepalive request that survives teardown.
  3. Offline: while navigator.onLine is false the SDK keeps buffering and makes no network attempts. The moment the online event fires, it flushes and resets any backoff.
  4. 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_id so the server can de-duplicate.
  5. Failure handling:
    • Network error / 429 / 5xx → retry with exponential backoff + jitter (honours Retry-After). Events are preserved.
    • 401 / 403 (bad key / disallowed origin) and 404 / 405 (misconfigured host / 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.
  6. 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 callback

Configuration 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-origin fetch sends Origin: null. Either:
    • add the literal string null to allowedOrigins (simple, but matches any file:// page), or
    • register a custom privileged scheme (e.g. app://) or serve the renderer over http://localhost, and allowlist that origin (tighter).

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/contenteditable values are never captured.
  • password fields, [data-analytics-mask] regions, and maskTextSelectors are redacted.
  • optOut() stops tracking and clears the local buffer; respectDnt honours the browser signal.
  • URLs and hrefs are captured as-is — use beforeSend to 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's optIn() / 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() exposes reset() 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 --noEmit

Architecture

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