6.6 KiB
Design: enterprise-analytics-browser
Tier
Browser-tier. package.json sets "browser" field; no node: imports in src/. Uses crypto.getRandomValues (Web Crypto), navigator.sendBeacon, DOM Storage, fetch, setInterval/setTimeout. All DOM-API touches are funnelled through narrow injectable seams so tests run under either happy-dom (default vitest env) or a synthetic adapter.
Module layout
packages/observability/monitor/analytics-browser/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts (barrel — explicit named re-exports)
error-codes/
error-codes.ts
error-codes.test.ts
error-codes.md
index.ts
types/
analytics.types.ts
analytics.types.md
constants/
index.ts
internal/
id/
id.ts
id.test.ts
id.md
index.ts
schema/
analytics-event-schema.ts
analytics-event-schema.test.ts
analytics-event-schema.md
index.ts
consent/
consent.ts
consent.test.ts
consent.md
index.ts
pii/
pii.ts
pii.test.ts
pii.md
index.ts
sampling/
sampling.ts
sampling.test.ts
sampling.md
index.ts
storage/
storage.ts
storage.test.ts
storage.md
index.ts
session/
session.ts
session.test.ts
session.md
index.ts
transport/
batch.ts
batch.test.ts
batch.md
beacon.ts
beacon.test.ts
beacon.md
index.ts
tracker/
tracker.ts
tracker.test.ts
tracker.md
index.ts
telemetry/
telemetry.ts
telemetry.test.ts
telemetry.md
index.ts
tests/
setup/
polyfills.ts
integration.test.ts
Injectable seams
The tracker has zero hard DOM dependencies. Every browser API is reached through an injected adapter:
Clock(@suaveplan/types) —now() → numberfor timestamps.TimersLike(@suaveplan/types) —setInterval/clearInterval/setTimeout/clearTimeout.Transport— produces side effects (fetchinBatchTransport,sendBeaconinBeaconTransport); tests pass a synchronous fake or an in-process HTTP route.StorageBackend— durable queue persistence.ConsentPolicy— gate decision; defaults toAllowAllPolicy.Sampler— sampling decision; defaultHeadSampler(1.0)(sample everything).IdGenerator— for anonymous / session IDs; defaults to web-cryptogenerateId().
Beacon transport accepts an injected sendBeacon function (defaults to navigator.sendBeacon.bind(navigator)) and a fetch fallback so tests can drive the path without happy-dom quirks.
Event-bus shape
export interface AnalyticsEventMap extends EventMap {
track: { event: AnalyticsEvent };
pageview: { url: string; properties: Record<string, unknown> };
identify: { userId: string; traits?: Record<string, unknown> };
flush: { eventCount: number; success: boolean; latencyMs: number };
drop: { reason: DropReason; event?: AnalyticsEvent };
error: { error: Error; context: string };
shutdown: Record<string, never>;
}
Single-payload object on every channel — matches EventBus.emit(type, payload).
Durable queue
AnalyticsTracker writes every accepted event into both the in-memory FIFO and the injected StorageBackend. On flush success, both copies clear together. On flush failure, in-memory copy is restored; storage copy stays. On boot, restore() drains the storage backend back into the in-memory queue.
Bundled backends:
MemoryStorageBackend— Map-based, default; suitable for SSR or tests.WebStorageBackend— DOMStorage(localStorage/sessionStorage); JSON-encoded;maxByteswatermark withQUEUE_FULLejection.
IndexedDBQueueStorage is intentionally out of scope until @suaveplan/storage-browser lands; the SPI is shaped so an IndexedDB adapter slots in without API churn.
Consent + sampling + PII pipeline
track(name, props)
└─ build AnalyticsEvent
└─ ConsentPolicy.isAllowed(event) ─── false → drop{reason:"consent"}, counter, return
└─ Sampler.shouldSample(event) ─ false → drop{reason:"sampling"}, counter, return
└─ scrubPII(event, rules) ── applied if rules present
└─ StorageBackend.enqueue + in-memory queue.push
└─ events.emit("track", { event })
└─ counter increment
Transport
BatchTransport.send(events):
- Build
{ events }JSON body. - Spawn
AbortController; arm withsetTimeoutMs(default 10_000 ms). await fetch(endpoint, { method:"POST", headers, body, signal }).response.ok→ success;4xx→TRANSPORT_FAILED(no retry); timeout →TRANSPORT_TIMEOUT;5xx/ network → exponential backoff (retryDelay * 2 ** attempt, capped atmaxRetries).
BeaconTransport.send(events):
navigator.sendBeacon(endpoint, blob)— returns boolean.- If
falseandfallbackToFetch=true→fetch(endpoint, { method:"POST", keepalive:true, body }).
OTel telemetry
Uses @opentelemetry/api metrics.getMeter("@suaveplan/analytics-browser"). Lazy-bound so no provider → no-op meter (no setup required for production). Tests install an InMemoryMetricExporter via tests/setup/polyfills.ts.
Error registry
defineCodes("ANALYTICS_BROWSER", ["INVALID_OPTIONS", "TRANSPORT_TIMEOUT", "TRANSPORT_FAILED", "QUEUE_FULL", "STORAGE_UNAVAILABLE", "SHUTDOWN", "INVALID_EVENT", "BEACON_REJECTED"] as const).
Tier purity rules
- No
node:import anywhere insrc/. - All randomness via
crypto.getRandomValues— fail-safe via Web Crypto polyfill in tests. - No top-level
navigator.*access at module load — only inside method bodies guarded bytypeof navigator !== "undefined".
File-size budget
tracker.tsupstream is 161 LOC. Genesis adds queue persistence + consent + sampling + telemetry hooks — projected ~220 LOC, well within the 300 hard cap.transport/batch.ts~120 LOC.transport/beacon.ts~70 LOC.storage/storage.ts~140 LOC (two backends co-located).session/session.ts~150 LOC.- All test files ≤ 500 LOC.
Test strategy
- Real
@suaveplan/eventsEventBus— no mocks. - Real
happy-domfor DOM globals. fetchinjected as a real in-process handler (records call + returnsResponse).setInterval/clearIntervaldriven by aFakeTimersadapter from@suaveplan/testing.- OTel
InMemoryMetricExporterfor assertion of every counter / histogram. - 100/100/100/100 coverage gate enforced via
vitest.config.tsthresholds.