# Capability Spec Delta: analytics-browser (analytics-browser) **Package**: `@suaveplan/analytics-browser` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Error code registry The package SHALL expose `ANALYTICS_BROWSER_CODES` defined via `@suaveplan/error`'s `defineCodes` with the eight canonical reasons (`INVALID_OPTIONS`, `TRANSPORT_TIMEOUT`, `TRANSPORT_FAILED`, `QUEUE_FULL`, `STORAGE_UNAVAILABLE`, `SHUTDOWN`, `INVALID_EVENT`, `BEACON_REJECTED`) and a typed `AnalyticsBrowserError` extending `SuaveplanError`. #### Scenario: every code is namespaced under `ANALYTICS_BROWSER.` - **WHEN** any value of `ANALYTICS_BROWSER_CODES` is read - **THEN** the value matches the prefix `ANALYTICS_BROWSER.` #### Scenario: typed error carries code + context - **WHEN** `new AnalyticsBrowserError(code, message, { context })` is constructed - **THEN** the instance is `instanceof AnalyticsBrowserError`, `error.code === code`, and `error.context` exposes the supplied bag **Verified by:** `src/error-codes/error-codes.test.ts::"namespaces every code under ANALYTICS_BROWSER."` AND `src/error-codes/error-codes.test.ts::"carries the supplied code, message, cause, and context"` --- ### Requirement: Cryptographically random ID generator The package SHALL expose an internal `generateId(): string` returning 32-character hex strings drawn from `crypto.getRandomValues` and used for both anonymous and session IDs. Two consecutive calls MUST produce different values. #### Scenario: returns 32 hex chars - **WHEN** `generateId()` is invoked - **THEN** the result matches `/^[0-9a-f]{32}$/` #### Scenario: distinct across calls - **WHEN** `generateId()` is invoked twice - **THEN** the two results are not equal **Verified by:** `src/internal/id/id.test.ts::"emits 32 hex chars"` AND `src/internal/id/id.test.ts::"never repeats across consecutive calls"` --- ### Requirement: Zod-validated `AnalyticsEvent` schema The package SHALL expose `AnalyticsEventSchema` (a Zod schema) and `validateAnalyticsEvent(input): AnalyticsEvent` that throws `AnalyticsBrowserError(INVALID_EVENT)` when the input fails parsing. #### Scenario: accepts a well-formed event - **WHEN** `validateAnalyticsEvent({ id, name, properties, timestamp, sessionId, anonymousId })` is called with valid values - **THEN** the returned value equals the input #### Scenario: rejects missing required fields - **WHEN** `validateAnalyticsEvent({ name: "x" })` is called - **THEN** an `AnalyticsBrowserError` with `code === ANALYTICS_BROWSER.INVALID_EVENT` is thrown **Verified by:** `src/schema/analytics-event-schema.test.ts::"accepts a well-formed event"` AND `src/schema/analytics-event-schema.test.ts::"rejects events missing required fields"` --- ### Requirement: Consent gate The package SHALL expose a `ConsentPolicy` interface, an `AllowAllPolicy` default, and a `DoNotTrackPolicy` honouring `navigator.doNotTrack === "1"`. The tracker MUST consult the policy before enqueueing any event and emit a `drop` event with reason `consent` when blocked. #### Scenario: DoNotTrack blocks events - **WHEN** `navigator.doNotTrack === "1"` and a `DoNotTrackPolicy` is consulted with any event - **THEN** `isAllowed(event)` returns `false` #### Scenario: AllowAllPolicy permits every event - **WHEN** `AllowAllPolicy.isAllowed(event)` is called with any event - **THEN** the return value is `true` **Verified by:** `src/consent/consent.test.ts::"DoNotTrackPolicy blocks when navigator.doNotTrack === '1'"` AND `src/consent/consent.test.ts::"AllowAllPolicy permits every event"` --- ### Requirement: PII scrubber The package SHALL expose `scrubPII(event, rules)` applying `redact` (replace value with `"[REDACTED]"`), `hash` (replace value with hex SHA-256-like fingerprint via Web Crypto), and `drop` (omit field) strategies per `ScrubRule[]`. Dotted field paths (`user.email`) SHALL resolve into nested `properties` objects. #### Scenario: redact replaces value with [REDACTED] - **WHEN** `scrubPII(event, [{ field: "email", strategy: "redact" }])` is called - **THEN** `result.properties.email === "[REDACTED]"` #### Scenario: drop removes the field entirely - **WHEN** `scrubPII(event, [{ field: "ssn", strategy: "drop" }])` is called - **THEN** `"ssn" in result.properties` is `false` #### Scenario: hash replaces value with a deterministic fingerprint - **WHEN** `scrubPII(event, [{ field: "phone", strategy: "hash" }])` is called twice on the same input - **THEN** both results expose the same non-empty fingerprint string distinct from the original value **Verified by:** `src/pii/pii.test.ts::"redact replaces value with [REDACTED]"` AND `src/pii/pii.test.ts::"drop removes the field entirely"` AND `src/pii/pii.test.ts::"hash produces a deterministic fingerprint"` --- ### Requirement: Head sampler with per-event overrides The package SHALL expose `HeadSampler` accepting a base sample rate in `[0, 1]` and an optional `overrides` map (`Record`). Per-event override rate takes precedence over the base rate. #### Scenario: rate=0 drops everything - **WHEN** `new HeadSampler(0).shouldSample(event)` is called - **THEN** the return value is `false` #### Scenario: rate=1 keeps everything - **WHEN** `new HeadSampler(1).shouldSample(event)` is called - **THEN** the return value is `true` #### Scenario: override beats base rate - **WHEN** `new HeadSampler(0, { "page": 1 }).shouldSample({ name: "page", … })` is called - **THEN** the return value is `true` **Verified by:** `src/sampling/sampling.test.ts::"HeadSampler rate=0 drops every event"` AND `src/sampling/sampling.test.ts::"HeadSampler rate=1 keeps every event"` AND `src/sampling/sampling.test.ts::"per-event override beats base rate"` --- ### Requirement: Pluggable storage backends The package SHALL expose a `StorageBackend` interface (`enqueue(events)`, `dequeueAll()`, `clear()`, `getDepth()`) with two bundled implementations: `MemoryStorageBackend` and `WebStorageBackend` (`Storage`-backed, JSON-encoded, `maxBytes` watermark). #### Scenario: memory backend round-trips events - **WHEN** events are enqueued into `MemoryStorageBackend` and then `dequeueAll()` is called - **THEN** the returned array equals the enqueued events and `getDepth()` returns `0` #### Scenario: web storage backend persists across instances - **WHEN** events are enqueued into `WebStorageBackend({ storage })` and a second instance is created with the same `storage` - **THEN** the second instance's `dequeueAll()` returns the enqueued events #### Scenario: web storage backend rejects overflow - **WHEN** events exceeding the configured `maxBytes` are enqueued - **THEN** `AnalyticsBrowserError` with code `ANALYTICS_BROWSER.QUEUE_FULL` is thrown **Verified by:** `src/storage/storage.test.ts::"MemoryStorageBackend round-trips events"` AND `src/storage/storage.test.ts::"WebStorageBackend persists across instances"` AND `src/storage/storage.test.ts::"WebStorageBackend rejects events exceeding maxBytes"` --- ### Requirement: SessionManager lifecycle The package SHALL expose `SessionManager` tracking session id, page-view count, duration, referrer, inactivity timeout, and exposing `getSessionId()`, `recordPageView(url, props)`, `touch()`, `getDuration()`, `getReferrer()`, `getPageViews()`, `isActive()`, `end()`, `renew()`, `getInfo()`. #### Scenario: renew generates a fresh session id - **WHEN** `renew()` is called on a `SessionManager` - **THEN** the post-renew `getSessionId()` differs from the pre-renew value and `getPageViews() === 0` #### Scenario: inactivity expires the session - **WHEN** `isActive()` is called after more than `timeoutMs` of inactivity - **THEN** the return value is `false` **Verified by:** `src/session/session.test.ts::"renew generates a fresh session id and zeroes page views"` AND `src/session/session.test.ts::"inactivity beyond timeoutMs deactivates the session"` --- ### Requirement: `BatchTransport` with AbortController + exponential backoff The package SHALL expose `BatchTransport` posting `{ events }` JSON to a configured endpoint via injected `fetchFn` (defaulting to `globalThis.fetch`). Every request MUST be wrapped in an `AbortController` armed with the configured `timeoutMs` (default 10_000) and SHALL throw `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` on abort. `4xx` responses MUST short-circuit (no retry). `5xx` / network errors MUST retry with exponential backoff `retryDelay * 2 ** attempt` up to `maxRetries`. #### Scenario: 200 response returns success - **WHEN** the injected fetch returns a `Response` with `status === 200` and a batch is sent - **THEN** `send()` resolves to `true` #### Scenario: 4xx short-circuits without retry - **WHEN** the injected fetch returns `status === 400` once - **THEN** `send()` resolves to `false` and the fetch is called exactly once #### Scenario: timeout aborts the fetch and throws `TRANSPORT_TIMEOUT` - **WHEN** the injected fetch never resolves and `timeoutMs` elapses - **THEN** `send()` rejects with an `AnalyticsBrowserError` of code `ANALYTICS_BROWSER.TRANSPORT_TIMEOUT` #### Scenario: 5xx retries with backoff - **WHEN** the injected fetch returns `status === 503` `maxRetries` times then `200` - **THEN** `send()` resolves to `true` and the fetch was called `maxRetries + 1` times **Verified by:** `src/transport/batch.test.ts::"200 response resolves to success"` AND `src/transport/batch.test.ts::"4xx response short-circuits with no retry"` AND `src/transport/batch.test.ts::"timeoutMs aborts the fetch and throws TRANSPORT_TIMEOUT"` AND `src/transport/batch.test.ts::"5xx retries with exponential backoff"` --- ### Requirement: `BeaconTransport` for unload flushes The package SHALL expose `BeaconTransport` delivering a batch via injected `sendBeaconFn` (defaulting to `navigator.sendBeacon.bind(navigator)`). On rejection (`sendBeacon` returns `false`), the transport MUST fall back to `fetch` with `keepalive: true` when `fallbackToFetch` is enabled, otherwise SHALL throw `AnalyticsBrowserError(BEACON_REJECTED)`. #### Scenario: sendBeacon true resolves to success - **WHEN** injected `sendBeaconFn` returns `true` - **THEN** `send()` resolves to `true` #### Scenario: sendBeacon false with fallback uses fetch - **WHEN** injected `sendBeaconFn` returns `false` and `fallbackToFetch === true` - **THEN** the injected `fetchFn` is invoked with `keepalive: true` #### Scenario: sendBeacon false without fallback throws - **WHEN** injected `sendBeaconFn` returns `false` and `fallbackToFetch === false` - **THEN** `send()` rejects with `AnalyticsBrowserError(BEACON_REJECTED)` **Verified by:** `src/transport/beacon.test.ts::"sendBeacon true resolves to success"` AND `src/transport/beacon.test.ts::"sendBeacon false with fallback uses fetch keepalive"` AND `src/transport/beacon.test.ts::"sendBeacon false without fallback throws BEACON_REJECTED"` --- ### Requirement: `AnalyticsTracker` event pipeline The package SHALL expose `AnalyticsTracker` accepting a `Transport` and partial `TrackerOptions` (`endpoint`, `batchSize`, `flushInterval`, `consentPolicy`, `sampler`, `scrubRules`, `storage`, `clock`, `timers`, `idGenerator`). Calling `track(name, props)` MUST: (1) consult `ConsentPolicy`; (2) consult `Sampler`; (3) apply PII scrubber if rules are set; (4) push to in-memory queue and storage backend; (5) emit `track` on the bus; (6) increment the `events_tracked_total` counter; (7) trigger `flush()` when queue ≥ `batchSize`. `track()` MUST be a no-op after `shutdown()`. #### Scenario: track enqueues a well-formed event - **WHEN** `tracker.track("clicked", { x: 1 })` is called on a fresh tracker - **THEN** `tracker.getQueueSize() === 1` and the bus emitted `track` with the event payload #### Scenario: consent block drops the event - **WHEN** the tracker is configured with a `ConsentPolicy` whose `isAllowed` returns `false` and `track()` is called - **THEN** `getQueueSize() === 0` and the bus emitted `drop` with `reason: "consent"` #### Scenario: sampling drop emits a `drop` event - **WHEN** the tracker is configured with `new HeadSampler(0)` and `track()` is called - **THEN** `getQueueSize() === 0` and the bus emitted `drop` with `reason: "sampling"` #### Scenario: PII scrubber redacts before enqueue - **WHEN** the tracker is configured with `[{ field: "email", strategy: "redact" }]` and `track("event", { email: "x@y" })` is called - **THEN** the enqueued event's `properties.email === "[REDACTED]"` #### Scenario: batch-size threshold triggers flush - **WHEN** `batchSize` events are tracked - **THEN** the configured transport's `send()` is invoked exactly once #### Scenario: shutdown drops new tracks and emits `shutdown` - **WHEN** `await tracker.shutdown()` resolves and `track("x")` is then called - **THEN** `getQueueSize() === 0` and the bus emitted `shutdown` exactly once **Verified by:** `src/tracker/tracker.test.ts::"track enqueues a well-formed event"` AND `src/tracker/tracker.test.ts::"consent block drops the event"` AND `src/tracker/tracker.test.ts::"sampling drop emits drop with reason sampling"` AND `src/tracker/tracker.test.ts::"PII scrubber redacts before enqueue"` AND `src/tracker/tracker.test.ts::"batch-size threshold triggers flush"` AND `src/tracker/tracker.test.ts::"shutdown ignores subsequent track calls"` --- ### Requirement: Durable storage round-trip The tracker SHALL persist accepted events into the injected `StorageBackend` and SHALL clear the backend after a successful flush. A new tracker constructed against the same backend MUST restore previously persisted events into its in-memory queue. #### Scenario: storage backend receives every accepted event - **WHEN** `track("a")` and `track("b")` are called and the configured backend is a `MemoryStorageBackend` - **THEN** `backend.getDepth() === 2` #### Scenario: successful flush clears the backend - **WHEN** flush succeeds against the configured transport - **THEN** `backend.getDepth() === 0` post-flush #### Scenario: new tracker restores from storage - **WHEN** a fresh tracker is constructed with a backend containing previously persisted events - **THEN** `getQueueSize()` equals the previously persisted depth after `restore()` resolves **Verified by:** `src/tracker/tracker.test.ts::"storage backend receives every accepted event"` AND `src/tracker/tracker.test.ts::"successful flush clears the storage backend"` AND `src/tracker/tracker.test.ts::"restore replays persisted events into the in-memory queue"` --- ### Requirement: Identify + global properties + session pinning The tracker SHALL expose `identify(userId, traits?)`, `setProperties(props)`, `setSessionId(id)`, `getAnonymousId()`, `getUserId()`, `getSessionId()`. `identify()` MUST emit `identify` on the bus. Subsequent `track()` calls MUST attach the current `userId`, `sessionId`, `anonymousId`, and merge global properties beneath per-call properties. #### Scenario: identify pins userId on subsequent events - **WHEN** `tracker.identify("u1")` then `tracker.track("a")` is called - **THEN** the enqueued event's `userId === "u1"` #### Scenario: setProperties merges into every event - **WHEN** `tracker.setProperties({ app: "shell" })` then `tracker.track("a", { x: 1 })` is called - **THEN** the enqueued event's properties equal `{ app: "shell", x: 1 }` **Verified by:** `src/tracker/tracker.test.ts::"identify pins userId on subsequent events"` AND `src/tracker/tracker.test.ts::"setProperties merges into every event"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL expose `eventsTrackedCounter`, `eventsFlushedCounter`, `eventsDroppedCounter`, `batchSizeHistogram`, `flushLatencyHistogram` via `@opentelemetry/api` and SHALL record on every tracked / flushed / dropped event. The flush counter MUST tag `status` (`success` / `failure`) and the drop counter MUST tag `reason` (`consent` / `sampling` / `queue_full` / `shutdown` / `invalid`). #### Scenario: tracked event increments counter - **WHEN** `tracker.track("x")` is called and metrics are flushed - **THEN** the `analytics_browser.events_tracked_total` counter exposes a non-zero data point #### Scenario: flush failure records `status=failure` - **WHEN** the configured transport returns `false` and a flush executes - **THEN** the `analytics_browser.events_flushed_total` counter exposes a data point with `status=failure` #### Scenario: consent drop records `reason=consent` - **WHEN** the consent gate blocks an event - **THEN** the `analytics_browser.events_dropped_total` counter exposes a data point with `reason=consent` **Verified by:** `src/telemetry/telemetry.test.ts::"records tracked events"` AND `src/telemetry/telemetry.test.ts::"records flush status"` AND `src/telemetry/telemetry.test.ts::"records drop reason"` --- ### Requirement: AbortController + timeout never leaks The package SHALL clean up every `AbortController` and `setTimeout` armed by `BatchTransport.send()` regardless of resolution path. A successful or failed send MUST NOT leave a dangling timer reference detectable through a real `TimersLike` adapter. #### Scenario: success clears the timeout - **WHEN** the injected fetch returns `200` and the test `TimersLike` records timer counts - **THEN** the cleared-timeout count equals the armed-timeout count #### Scenario: timeout abort clears the timeout - **WHEN** the fetch never resolves and the timeout fires - **THEN** the cleared-timeout count equals the armed-timeout count **Verified by:** `src/transport/batch.test.ts::"successful send clears the armed timeout"` AND `src/transport/batch.test.ts::"timeout abort clears the armed timeout"` --- ### Requirement: Tier purity — no `node:` imports The package SHALL contain zero `from "node:*"` imports in `src/` to preserve browser-tier portability. A repository-level grep MUST return zero matches inside the package. #### Scenario: src contains no node: imports - **WHEN** `grep -rn "from \"node:" packages/observability/monitor/analytics-browser/src/` is executed - **THEN** the grep produces no output **Verified by:** `tests/integration.test.ts::"src/ contains no node: imports"` --- ### Requirement: Integration — end-to-end track → batch → http round-trip The package SHALL be integration-tested with a real `EventBus`, a real `BatchTransport`, and an in-process fetch handler. The handler MUST observe a `POST` body containing the tracked events, and the bus MUST emit `track` then `flush` events in order. #### Scenario: tracked event arrives at the in-process endpoint - **WHEN** `tracker.track("e")` is called with `batchSize: 1` against a real `BatchTransport` whose `fetchFn` is an in-process handler - **THEN** the handler observes a single `POST` carrying one event with `name === "e"` and the bus emitted `track` followed by `flush` with `success: true` **Verified by:** `tests/integration.test.ts::"tracker → batch transport → in-process endpoint round-trip"`