144 lines
7.6 KiB
Markdown
144 lines
7.6 KiB
Markdown
# 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
|
||
|
||
```ts
|
||
// 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.
|