Files
2026-05-27 23:29:53 +01:00

363 lines
19 KiB
Markdown

# 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<AnalyticsBrowserCode>`.
#### 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<EventName, rate>`). 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"`