Headless library

Analytics

@refraction-ui/analytics is a neutral Segment-spec collector/router. The app instruments once and never names a vendor; the library fans the canonical event envelope out to N pluggable sinks (GA4, Azure App Insights, PostHog, your own backend). There is no privileged engine — every vendor is just a sink.

Consumer API

The entire public surface is the Segment Spec. Instrument once; the router handles batching, sessions, identity, consent and fan-out.

tsx
import { createAnalytics } from '@refraction-ui/analytics'

const analytics = createAnalytics({
  app: 'my-app',
  env: process.env.NODE_ENV,           // drives dev/prod presets
  endpoint: 'https://collect.example.com', // optional: auto-adds the http sink
  writeKey: 'WRITE_KEY',
  enabled: true,                       // false → tree-shakeable noop
  sampleRate: 1,                       // 0..1, applied per call
  redactKeys: ['internalScore'],       // extra PII keys to strip
  consent: { granted: ['analytics'] },
})

// Segment Spec — the entire instrumentation surface (never name a vendor)
analytics.track('Signup Clicked', { plan: 'pro' })
analytics.identify('user_42', { plan: 'pro' })
analytics.page('Pricing', { path: '/pricing' })
analytics.screen('Dashboard')
analytics.group('org_7', { name: 'Acme' })
analytics.alias('user_42', 'anon-or-prev-id')

// Analytics session (NOT replay) · consent gate · child context
analytics.session.id()
analytics.consent.grant('marketing')
const checkout = analytics.with({ feature: 'checkout' })

await analytics.flush()                // drain batch + flush all sinks
analytics.reset()                      // privacy-safe logout

React

@refraction-ui/react-analytics mirrors react-ai: a provider plus hooks. No vendor SDK is loaded in the browser.

tsx
import { AnalyticsProvider, useAnalytics, useTrackEvent } from '@refraction-ui/react-analytics'

function App() {
  return (
    <AnalyticsProvider config={{ app: 'my-app', env: 'production', endpoint, writeKey }}>
      <Pricing />
    </AnalyticsProvider>
  )
}

function Pricing() {
  const analytics = useAnalytics()
  const track = useTrackEvent()            // stable, memoised
  return <button onClick={() => track('Plan Selected', { plan: 'pro' })}>Pro</button>
}

Dev vs. prod

The env drives a preset. Development is synchronous with a console sink so engineers see exactly what would ship; production batches, samples and flushes via sendBeacon on unload.

ts
// dev  (env !== 'production'): synchronous, unbatched + a console sink
//                              so engineers see exactly what would ship.
// prod (env === 'production'): batching + sampling + a sendBeacon flush
//                              bound to pagehide / visibilitychange.
// enabled: false             : returns a noop — the live collector and all
//                              sink code tree-shake out of the bundle.

createAnalytics({ app: 'my-app', env: 'production' })   // → prod preset
createAnalytics({ app: 'my-app', env: 'development' })  // → dev preset
createAnalytics({ app: 'my-app', env, enabled: false }) // → noop

Sessions, identity & consent

  • sessionId — client UUIDv4 minted at session start. A session ends after 30 min of inactivity (GA4 parity, configurable) or when a new campaign (UTM / gclid / fbclid / msclkid) is detected. Persisted cross-tab via localStorage → cookie → memory.
  • anonymousId — persistent, non-PII, resettable UUIDv4. reset() mints a fresh one so a new visitor is never stitched to the old one.
  • userId — opaque, app-supplied; never persisted by the library. alias emits a previousId → userId stitch event.
  • PII — a built-in deny-list (email / phone / name / password / card / …) plus redactKeys; case- and separator-insensitive, recursing into nested objects/arrays. Values become "[REDACTED]".
  • Consent — each sink declares the categories it requires; the router will not deliver to a sink unless all its categories are granted (per-sink consent).

GA4 collection-parity

GA4 is one sink. Collection reaches parity (and beyond) via optional auto-capture plugins; identity and parameters map directly — anonymousId → client_id, userId → user_id, sessionId → session_id, track → a snake_cased GA4 event, page → page_view, screen → screen_view. Reporting is delegated: route the GA4 sink and you get GA’s exact reports unchanged.

Backend wire contract

The built-in http sink implements the Segment HTTP Tracking API batch format — adopt, do not invent — so RudderStack / Jitsu / Segment are drop-in conforming backends.

Request

bash
POST {endpoint}/v{schemaVersion}/batch        (schemaVersion = 1 → /v1/batch)
Content-Type: application/json                 (gzip optional)
Authorization: Basic base64("{writeKey}:")     ← note the trailing colon

{
  "batch":   [ /* AnalyticsEvent[] — canonical envelope */ ],
  "sentAt":  "2026-05-16T12:00:00.000Z",        // honest client send time
  "batchId": "<uuid v4>"
}

Canonical event envelope

json
{
  "type": "track",                  // track|identify|page|screen|group|alias
  "event": "Signup Clicked",        // track/page/screen name (optional)
  "messageId": "<uuid v4>",         // idempotency key — backends MUST dedupe
  "anonymousId": "<uuid v4>",       // persistent, non-PII
  "userId": "user_42",              // opaque, optional
  "sessionId": "<uuid v4>",         // analytics session
  "properties": { },                // track/page/screen/group payload
  "traits": { },                    // identify/group traits
  "context": { "app": "my-app", "env": "production",
               "library": { "name": "@refraction-ui/analytics", "version": "0.1.0" } },
  "timestamp": "2026-05-16T12:00:00.000Z",      // ISO-8601 client time
  "schemaVersion": 1
}

Response semantics (accept-and-queue)

StatusMeaningClient behaviour
2xxAccepted and queued (not yet processed)done
400Malformeddrop, never retry
401Bad write keydrop, never retry
413Payload too largedrop (client also pre-splits)
429 / 5xxTransient / overloadexponential backoff retry
network errtreated as transient → retry
  • Idempotency — backends MUST dedupe on messageId.
  • Clock skew — the backend corrects time as corrected = timestamp + (receivedAt − sentAt); the client always stamps an honest sentAt.
  • Size limits — soft caps of ≈ 500 KB / batch and ≈ 32 KB / event (Segment parity). The client pre-splits oversized batches and drops over-cap events.
  • Versioning — the path carries /v{schemaVersion}/; every event also carries schemaVersion.

sendBeacon caveat (unload path)

bash
// Unload path: navigator.sendBeacon cannot set an Authorization header.
// A conforming backend MUST also accept the write key via query + text/plain:
POST {endpoint}/v1/batch?writeKey={writeKey}
Content-Type: text/plain

Recommended topology: server relay

The recommended production topology is a server relay: the browser ships only our neutral router to your endpoint; your backend fans out to vendors server-side. This is ad-blocker-proof and keeps vendor SDKs out of the bundle.

text
Browser                         Your backend (the relay)        Vendors
────────                         ─────────────────────────       ───────
analytics.track(...)             POST /v1/batch                   GA4  (Measurement Protocol)
   │  neutral router only   ───▶   ├─ auth (Basic | ?writeKey=)     ▲
   │  (no vendor SDK)              ├─ dedupe messageId              │ server-side
   ▼                              ├─ clock-skew correct            │ fan-out
sendBeacon on unload  ─────────▶  ├─ size caps / schemaVersion ────┤ (ad-blocker-proof)
                                  └─ 200 accept-and-queue          ▼
                                                                  Azure App Insights

The repo ships an executable, dependency-free reference relay, @refraction-ui/analytics-relay-reference (internal — never published). It implements the wire contract above and fans out to GA4 (Measurement Protocol) and Azure App Insights. The core http sink is conformance-tested against it end-to-end over a real socket (batch, beacon path, retry codes, dedupe, clock-skew, fan-out).

ts
import {
  createNodeRelayServer,
  createGA4Forwarder,
  createAzureForwarder,
} from '@refraction-ui/analytics-relay-reference'

const srv = createNodeRelayServer({
  writeKeys: ['WRITE_KEY'],
  forwarders: [
    createGA4Forwarder({ measurementId: 'G-XXXX', apiSecret, fetchImpl }),
    createAzureForwarder({ instrumentationKey, fetchImpl }),
  ],
})
const base = await srv.listen(8080) // POST {base}/v1/batch