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_CODESis 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, anderror.contextexposes 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
AnalyticsBrowserErrorwithcode === ANALYTICS_BROWSER.INVALID_EVENTis 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 aDoNotTrackPolicyis consulted with any event - THEN
isAllowed(event)returnsfalse
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.propertiesisfalse
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
MemoryStorageBackendand thendequeueAll()is called - THEN the returned array equals the enqueued events and
getDepth()returns0
Scenario: web storage backend persists across instances
- WHEN events are enqueued into
WebStorageBackend({ storage })and a second instance is created with the samestorage - THEN the second instance's
dequeueAll()returns the enqueued events
Scenario: web storage backend rejects overflow
- WHEN events exceeding the configured
maxBytesare enqueued - THEN
AnalyticsBrowserErrorwith codeANALYTICS_BROWSER.QUEUE_FULLis 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 aSessionManager - THEN the post-renew
getSessionId()differs from the pre-renew value andgetPageViews() === 0
Scenario: inactivity expires the session
- WHEN
isActive()is called after more thantimeoutMsof 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
Responsewithstatus === 200and a batch is sent - THEN
send()resolves totrue
Scenario: 4xx short-circuits without retry
- WHEN the injected fetch returns
status === 400once - THEN
send()resolves tofalseand the fetch is called exactly once
Scenario: timeout aborts the fetch and throws TRANSPORT_TIMEOUT
- WHEN the injected fetch never resolves and
timeoutMselapses - THEN
send()rejects with anAnalyticsBrowserErrorof codeANALYTICS_BROWSER.TRANSPORT_TIMEOUT
Scenario: 5xx retries with backoff
- WHEN the injected fetch returns
status === 503maxRetriestimes then200 - THEN
send()resolves totrueand the fetch was calledmaxRetries + 1times
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
sendBeaconFnreturnstrue - THEN
send()resolves totrue
Scenario: sendBeacon false with fallback uses fetch
- WHEN injected
sendBeaconFnreturnsfalseandfallbackToFetch === true - THEN the injected
fetchFnis invoked withkeepalive: true
Scenario: sendBeacon false without fallback throws
- WHEN injected
sendBeaconFnreturnsfalseandfallbackToFetch === false - THEN
send()rejects withAnalyticsBrowserError(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() === 1and the bus emittedtrackwith the event payload
Scenario: consent block drops the event
- WHEN the tracker is configured with a
ConsentPolicywhoseisAllowedreturnsfalseandtrack()is called - THEN
getQueueSize() === 0and the bus emitteddropwithreason: "consent"
Scenario: sampling drop emits a drop event
- WHEN the tracker is configured with
new HeadSampler(0)andtrack()is called - THEN
getQueueSize() === 0and the bus emitteddropwithreason: "sampling"
Scenario: PII scrubber redacts before enqueue
- WHEN the tracker is configured with
[{ field: "email", strategy: "redact" }]andtrack("event", { email: "x@y" })is called - THEN the enqueued event's
properties.email === "[REDACTED]"
Scenario: batch-size threshold triggers flush
- WHEN
batchSizeevents 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 andtrack("x")is then called - THEN
getQueueSize() === 0and the bus emittedshutdownexactly 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")andtrack("b")are called and the configured backend is aMemoryStorageBackend - THEN
backend.getDepth() === 2
Scenario: successful flush clears the backend
- WHEN flush succeeds against the configured transport
- THEN
backend.getDepth() === 0post-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 afterrestore()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")thentracker.track("a")is called - THEN the enqueued event's
userId === "u1"
Scenario: setProperties merges into every event
- WHEN
tracker.setProperties({ app: "shell" })thentracker.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_totalcounter exposes a non-zero data point
Scenario: flush failure records status=failure
- WHEN the configured transport returns
falseand a flush executes - THEN the
analytics_browser.events_flushed_totalcounter exposes a data point withstatus=failure
Scenario: consent drop records reason=consent
- WHEN the consent gate blocks an event
- THEN the
analytics_browser.events_dropped_totalcounter exposes a data point withreason=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
200and the testTimersLikerecords 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 withbatchSize: 1against a realBatchTransportwhosefetchFnis an in-process handler - THEN the handler observes a single
POSTcarrying one event withname === "e"and the bus emittedtrackfollowed byflushwithsuccess: true
Verified by: tests/integration.test.ts::"tracker → batch transport → in-process endpoint round-trip"