Files
suaveplan/openspec/archive/2026-05-25-enterprise-analytics-browser/design.md
2026-05-27 23:29:53 +01:00

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() → number for timestamps.
  • TimersLike (@suaveplan/types) — setInterval / clearInterval / setTimeout / clearTimeout.
  • Transport — produces side effects (fetch in BatchTransport, sendBeacon in BeaconTransport); tests pass a synchronous fake or an in-process HTTP route.
  • StorageBackend — durable queue persistence.
  • ConsentPolicy — gate decision; defaults to AllowAllPolicy.
  • Sampler — sampling decision; default HeadSampler(1.0) (sample everything).
  • IdGenerator — for anonymous / session IDs; defaults to web-crypto generateId().

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 — DOM Storage (localStorage / sessionStorage); JSON-encoded; maxBytes watermark with QUEUE_FULL ejection.

IndexedDBQueueStorage is intentionally out of scope until @suaveplan/storage-browser lands; the SPI is shaped so an IndexedDB adapter slots in without API churn.

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

  1. Build { events } JSON body.
  2. Spawn AbortController; arm with setTimeoutMs (default 10_000 ms).
  3. await fetch(endpoint, { method:"POST", headers, body, signal }).
  4. response.ok → success; 4xx → TRANSPORT_FAILED (no retry); timeout → TRANSPORT_TIMEOUT; 5xx / network → exponential backoff (retryDelay * 2 ** attempt, capped at maxRetries).

BeaconTransport.send(events):

  1. navigator.sendBeacon(endpoint, blob) — returns boolean.
  2. If false and fallbackToFetch=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 in src/.
  • 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 by typeof navigator !== "undefined".

File-size budget

  • tracker.ts upstream 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/events EventBus — no mocks.
  • Real happy-dom for DOM globals.
  • fetch injected as a real in-process handler (records call + returns Response).
  • setInterval/clearInterval driven by a FakeTimers adapter from @suaveplan/testing.
  • OTel InMemoryMetricExporter for assertion of every counter / histogram.
  • 100/100/100/100 coverage gate enforced via vitest.config.ts thresholds.