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

7.6 KiB
Raw Blame History

OpenSpec Change: enterprise-analytics-browser

  • Change ID: enterprise-analytics-browser
  • Package: @suaveplan/analytics-browser
  • Target path: packages/observability/monitor/analytics-browser
  • Status: PROPOSED
  • Date: 2026-05-25
  • Upstream reference: /mnt/s/web/suaveplan/enterprise/analytics-browser.md
  • Design: ./design.md
  • Spec delta: ./specs/analytics-browser/spec.md

Summary

Land @suaveplan/analytics-browser on genesis at enterprise quality. The package provides browser-tier client-side product analytics: AnalyticsTracker (queue + auto-flush + identify + global props + session pin), BatchTransport (HTTP POST + retry + 4xx short-circuit + AbortController/timeout), BeaconTransport (navigator.sendBeacon / fetch keepalive for page-unload), and SessionManager (id + page views + duration + referrer + inactivity timeout). Adds enterprise-grade additions absent from upstream: AbortController + timeoutMs on every fetch, injectable Transport/StorageBackend/Clock/TimersLike, durable offline queue (via injected StorageBackend adapter — in-memory + localStorage adapters bundled, IndexedDB adapter shipped when @suaveplan/storage-browser lands), consent gate (DoNotTrackPolicy + AllowAllPolicy + ConsentPolicy SPI), PII scrubber (scrubPII + redact/hash/drop strategies), Zod-validated AnalyticsEventSchema, sampling (head-based + per-event override), shared generateId(), OTel counters/histograms, ANALYTICS_BROWSER.* error registry, and a unified EventBus<AnalyticsEventMap> lifecycle bus consumed via the genesis @suaveplan/events substrate.


Current-state audit (genesis)

Fresh build — does not exist on genesis. Listed – in STATUS.md line 548.

Upstream audit

  • packages/browser/analytics-browser/ upstream is 1.0.6, ~390 LOC across 4 files (tracker 161, transport 88, session 144, types 97).
  • Upstream consumes EventEmitter<AnalyticsEventMap> from @suaveplan/events. The genesis @suaveplan/events substrate exposes EventBus<TMap> (single-payload signature) — migration translates tuple-style payloads into single-object payloads on the same EventMap shape.
  • Upstream uses crypto.getRandomValues for IDs duplicated across tracker.ts and session.ts. Genesis extracts to src/internal/id/id.ts.

Available workspace deps on genesis

Package Role
@suaveplan/events (workspace:^) EventBus<AnalyticsEventMap>
@suaveplan/error (workspace:^) defineCodes / SuaveplanError
@suaveplan/types (workspace:^) Clock, TimersLike, LoggerLike contracts
@suaveplan/logger (workspace:^) optional structured logging via LoggerLike
@suaveplan/opentelemetry (workspace:^) OTel resource builder (peer; runtime via @opentelemetry/api)
@opentelemetry/api (catalog:observability) OTel meter / counters / histograms
zod (catalog:forms) AnalyticsEvent Zod schema
tslib (catalog:build) runtime helpers

Dev: vitest catalog:testing, @vitest/coverage-istanbul catalog:testing, happy-dom catalog:testing, @suaveplan/testing workspace:^, @opentelemetry/sdk-metrics.

@suaveplan/storage-browser is NOT on Verdaccio yet — durable queue ships against an injectable StorageBackend SPI with bundled MemoryStorageBackend and WebStorageBackend (works against any DOM Storage — localStorage / sessionStorage).

Preserved + extended public surface

// Tracker
export class AnalyticsTracker {
  readonly events: EventBus<AnalyticsEventMap>;
  constructor(transport: Transport, options?: Partial<TrackerOptions>);
  track(name: string, properties?: Record<string, unknown>): void;
  page(url?: string, properties?: Record<string, unknown>): void;
  identify(userId: string, traits?: Record<string, unknown>): void;
  setProperties(properties: Record<string, unknown>): void;
  getAnonymousId(): string;
  getUserId(): string | undefined;
  getSessionId(): string;
  setSessionId(sessionId: string): void;
  getQueueSize(): number;
  flush(): Promise<boolean>;
  shutdown(): Promise<void>;
}

// Transports
export interface Transport {
  send(events: AnalyticsEvent[]): Promise<boolean>;
  shutdown(): Promise<void>;
}
export class BatchTransport implements Transport { /* AbortController + timeoutMs + retry */ }
export class BeaconTransport implements Transport { /* sendBeacon + keepalive fallback */ }

// Session
export class SessionManager { /* getSessionId / recordPageView / touch / getDuration / renew / end / getInfo */ }

// Storage backends
export interface StorageBackend {
  enqueue(events: AnalyticsEvent[]): Promise<void>;
  dequeueAll(): Promise<AnalyticsEvent[]>;
  clear(): Promise<void>;
  getDepth(): Promise<number>;
}
export class MemoryStorageBackend implements StorageBackend {}
export class WebStorageBackend implements StorageBackend { constructor(opts: { storage: Storage; key?: string; maxBytes?: number }); }

// Consent
export interface ConsentPolicy { isAllowed(event: AnalyticsEvent): boolean; }
export class AllowAllPolicy implements ConsentPolicy {}
export class DoNotTrackPolicy implements ConsentPolicy {}

// PII scrubber
export type ScrubStrategy = "redact" | "hash" | "drop";
export interface ScrubRule { field: string; strategy: ScrubStrategy; }
export function scrubPII(event: AnalyticsEvent, rules: readonly ScrubRule[]): AnalyticsEvent;

// Sampling
export interface Sampler { shouldSample(event: AnalyticsEvent): boolean; }
export class HeadSampler implements Sampler { constructor(rate: number, overrides?: Record<string, number>); }

// Schema
export const AnalyticsEventSchema: z.ZodType<AnalyticsEvent>;

// Errors
export const ANALYTICS_BROWSER_CODES;
export class AnalyticsBrowserError extends SuaveplanError<AnalyticsBrowserCode> {}

// Telemetry
export const eventsTrackedCounter: Counter;
export const eventsFlushedCounter: Counter;
export const eventsDroppedCounter: Counter;
export const batchSizeHistogram: Histogram;
export const flushLatencyHistogram: Histogram;

Subpath exports: ".", "./tracker", "./transport", "./session", "./consent", "./pii", "./sampling", "./storage", "./schema", "./error-codes", "./telemetry", "./types".

Error codes

ANALYTICS_BROWSER.INVALID_OPTIONS, ANALYTICS_BROWSER.TRANSPORT_TIMEOUT, ANALYTICS_BROWSER.TRANSPORT_FAILED, ANALYTICS_BROWSER.QUEUE_FULL, ANALYTICS_BROWSER.STORAGE_UNAVAILABLE, ANALYTICS_BROWSER.SHUTDOWN, ANALYTICS_BROWSER.INVALID_EVENT, ANALYTICS_BROWSER.BEACON_REJECTED.

OTel instruments

  • Counter analytics_browser.events_tracked_total{name}
  • Counter analytics_browser.events_flushed_total{status} (success / failure)
  • Counter analytics_browser.events_dropped_total{reason} (consent, sampling, queue_full, shutdown, invalid)
  • Histogram analytics_browser.batch_size
  • Histogram analytics_browser.flush_latency_ms

Impact

Affected dependents

No dependents on genesis yet (verified by bunx turbo ls --filter=...@suaveplan/analytics-browser). Downstream: React SPA shells, R3F apps. New code paths are additive — existing instantiation new AnalyticsTracker(transport) continues to work.

Breaking changes

None.

Migration notes

Upstream API (new AnalyticsTracker(transport, config)) preserved verbatim with one signature evolution: the lifecycle bus is EventBus<AnalyticsEventMap> instead of EventEmitter. The map's payload shape changes from tuple [event] to single-object { event } — call sites change .on("track", (event) => …) to .on("track", ({ event }) => …). This is the only mechanical change required by the genesis @suaveplan/events substrate.