Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
# 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.