Files
suaveplan/openspec/specs/analytics-browser/spec.md
2026-05-27 23:29:53 +01:00

19 KiB

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"


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
  • 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
  • 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"