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.
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 logoutReact
@refraction-ui/react-analytics mirrors react-ai: a provider plus hooks. No vendor SDK is loaded in the browser.
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.
// 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 }) // → noopSessions, 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.
aliasemits apreviousId → userIdstitch 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
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
{
"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)
| Status | Meaning | Client behaviour | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ||||||||||||||||||||
- Idempotency — backends MUST dedupe on
messageId. - Clock skew — the backend corrects time as
corrected = timestamp + (receivedAt − sentAt); the client always stamps an honestsentAt. - 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 carriesschemaVersion.
sendBeacon caveat (unload path)
// 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/plainRecommended 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.
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 InsightsThe 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).
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