7.6 KiB
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/eventssubstrate exposesEventBus<TMap>(single-payload signature) — migration translates tuple-style payloads into single-object payloads on the sameEventMapshape. - Upstream uses
crypto.getRandomValuesfor IDs duplicated acrosstracker.tsandsession.ts. Genesis extracts tosrc/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.