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,186 @@
# Design: enterprise-analytics-browser
## Tier
Browser-tier. `package.json` sets `"browser"` field; no `node:` imports in `src/`. Uses `crypto.getRandomValues` (Web Crypto), `navigator.sendBeacon`, DOM `Storage`, `fetch`, `setInterval`/`setTimeout`. All DOM-API touches are funnelled through narrow injectable seams so tests run under either `happy-dom` (default vitest env) or a synthetic adapter.
## Module layout
```
packages/observability/monitor/analytics-browser/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts (barrel — explicit named re-exports)
error-codes/
error-codes.ts
error-codes.test.ts
error-codes.md
index.ts
types/
analytics.types.ts
analytics.types.md
constants/
index.ts
internal/
id/
id.ts
id.test.ts
id.md
index.ts
schema/
analytics-event-schema.ts
analytics-event-schema.test.ts
analytics-event-schema.md
index.ts
consent/
consent.ts
consent.test.ts
consent.md
index.ts
pii/
pii.ts
pii.test.ts
pii.md
index.ts
sampling/
sampling.ts
sampling.test.ts
sampling.md
index.ts
storage/
storage.ts
storage.test.ts
storage.md
index.ts
session/
session.ts
session.test.ts
session.md
index.ts
transport/
batch.ts
batch.test.ts
batch.md
beacon.ts
beacon.test.ts
beacon.md
index.ts
tracker/
tracker.ts
tracker.test.ts
tracker.md
index.ts
telemetry/
telemetry.ts
telemetry.test.ts
telemetry.md
index.ts
tests/
setup/
polyfills.ts
integration.test.ts
```
## Injectable seams
The tracker has zero hard DOM dependencies. Every browser API is reached through an injected adapter:
- `Clock` (`@suaveplan/types`) — `now() → number` for timestamps.
- `TimersLike` (`@suaveplan/types`) — `setInterval` / `clearInterval` / `setTimeout` / `clearTimeout`.
- `Transport` — produces side effects (`fetch` in `BatchTransport`, `sendBeacon` in `BeaconTransport`); tests pass a synchronous fake or an in-process HTTP route.
- `StorageBackend` — durable queue persistence.
- `ConsentPolicy` — gate decision; defaults to `AllowAllPolicy`.
- `Sampler` — sampling decision; default `HeadSampler(1.0)` (sample everything).
- `IdGenerator` — for anonymous / session IDs; defaults to web-crypto `generateId()`.
Beacon transport accepts an injected `sendBeacon` function (defaults to `navigator.sendBeacon.bind(navigator)`) and a fetch fallback so tests can drive the path without `happy-dom` quirks.
## Event-bus shape
```ts
export interface AnalyticsEventMap extends EventMap {
track: { event: AnalyticsEvent };
pageview: { url: string; properties: Record<string, unknown> };
identify: { userId: string; traits?: Record<string, unknown> };
flush: { eventCount: number; success: boolean; latencyMs: number };
drop: { reason: DropReason; event?: AnalyticsEvent };
error: { error: Error; context: string };
shutdown: Record<string, never>;
}
```
Single-payload object on every channel — matches `EventBus.emit(type, payload)`.
## Durable queue
`AnalyticsTracker` writes every accepted event into both the in-memory FIFO and the injected `StorageBackend`. On flush success, both copies clear together. On flush failure, in-memory copy is restored; storage copy stays. On boot, `restore()` drains the storage backend back into the in-memory queue.
Bundled backends:
- `MemoryStorageBackend` — Map-based, default; suitable for SSR or tests.
- `WebStorageBackend` — DOM `Storage` (`localStorage` / `sessionStorage`); JSON-encoded; `maxBytes` watermark with `QUEUE_FULL` ejection.
`IndexedDBQueueStorage` is intentionally out of scope until `@suaveplan/storage-browser` lands; the SPI is shaped so an IndexedDB adapter slots in without API churn.
## Consent + sampling + PII pipeline
```text
track(name, props)
└─ build AnalyticsEvent
└─ ConsentPolicy.isAllowed(event) ─── false → drop{reason:"consent"}, counter, return
└─ Sampler.shouldSample(event) ─ false → drop{reason:"sampling"}, counter, return
└─ scrubPII(event, rules) ── applied if rules present
└─ StorageBackend.enqueue + in-memory queue.push
└─ events.emit("track", { event })
└─ counter increment
```
## Transport
`BatchTransport.send(events)`:
1. Build `{ events }` JSON body.
2. Spawn `AbortController`; arm with `setTimeoutMs` (default 10_000 ms).
3. `await fetch(endpoint, { method:"POST", headers, body, signal })`.
4. `response.ok` → success; `4xx` → `TRANSPORT_FAILED` (no retry); timeout → `TRANSPORT_TIMEOUT`; `5xx` / network → exponential backoff (`retryDelay * 2 ** attempt`, capped at `maxRetries`).
`BeaconTransport.send(events)`:
1. `navigator.sendBeacon(endpoint, blob)` — returns boolean.
2. If `false` and `fallbackToFetch=true` → `fetch(endpoint, { method:"POST", keepalive:true, body })`.
## OTel telemetry
Uses `@opentelemetry/api` `metrics.getMeter("@suaveplan/analytics-browser")`. Lazy-bound so no provider → no-op meter (no setup required for production). Tests install an `InMemoryMetricExporter` via `tests/setup/polyfills.ts`.
## Error registry
`defineCodes("ANALYTICS_BROWSER", ["INVALID_OPTIONS", "TRANSPORT_TIMEOUT", "TRANSPORT_FAILED", "QUEUE_FULL", "STORAGE_UNAVAILABLE", "SHUTDOWN", "INVALID_EVENT", "BEACON_REJECTED"] as const)`.
## Tier purity rules
- No `node:` import anywhere in `src/`.
- All randomness via `crypto.getRandomValues` — fail-safe via Web Crypto polyfill in tests.
- No top-level `navigator.*` access at module load — only inside method bodies guarded by `typeof navigator !== "undefined"`.
## File-size budget
- `tracker.ts` upstream is 161 LOC. Genesis adds queue persistence + consent + sampling + telemetry hooks — projected ~220 LOC, well within the 300 hard cap.
- `transport/batch.ts` ~120 LOC.
- `transport/beacon.ts` ~70 LOC.
- `storage/storage.ts` ~140 LOC (two backends co-located).
- `session/session.ts` ~150 LOC.
- All test files ≤ 500 LOC.
## Test strategy
- Real `@suaveplan/events` `EventBus` — no mocks.
- Real `happy-dom` for DOM globals.
- `fetch` injected as a real in-process handler (records call + returns `Response`).
- `setInterval`/`clearInterval` driven by a `FakeTimers` adapter from `@suaveplan/testing`.
- OTel `InMemoryMetricExporter` for assertion of every counter / histogram.
- 100/100/100/100 coverage gate enforced via `vitest.config.ts` `thresholds`.

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.

View File

@@ -0,0 +1,362 @@
# 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"`

View File

@@ -0,0 +1,113 @@
# Tasks: enterprise-analytics-browser
**Change ID**: enterprise-analytics-browser
**Package**: `@suaveplan/analytics-browser`
**Target path**: `packages/observability/monitor/analytics-browser`
---
## 0. Pre-flight
- [x] 0.1 `git worktree list` + `git status` per worktree.
- [x] 0.2 Worktree HEAD fast-forwarded to `b22864397b…` (genesis baseline).
- [x] 0.3 Confirmed package set in `MANIFEST.md` (regenerated post-merge).
## 1. Scaffold
- [x] Create directory tree per `design.md`.
- [x] `package.json` v0.1.0, `"browser"` field, `"sideEffects": false`, subpath exports.
- [x] `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (happy-dom, pool: forks, istanbul 100/100/100/100).
## 2. Error codes (`src/error-codes/`)
- [x] `error-codes.ts` — `ANALYTICS_BROWSER_CODES` (8) + `AnalyticsBrowserError`.
- [x] `error-codes.test.ts`, `error-codes.md`, `index.ts`.
## 3. Types (`src/types/`)
- [x] `analytics.types.ts` — `AnalyticsEvent`, `TrackerOptions`, `Transport`, `AnalyticsEventMap`, `StorageBackend`, `SessionInfo`, `SessionOptions`, `BatchTransportOptions`, `BeaconTransportOptions`, `ScrubRule`, `ScrubStrategy`, `ConsentPolicy`, `Sampler`, `DropReason`.
- [x] `analytics.types.md`.
## 4. Constants (`src/constants/`)
- [x] `DEFAULT_TRACKER_OPTIONS`, `DEFAULT_BATCH_OPTIONS`, `DEFAULT_BEACON_OPTIONS`, `DEFAULT_SESSION_TIMEOUT_MS`.
## 5. ID generator (`src/internal/id/`)
- [x] `id.ts` — `generateId()` using `crypto.getRandomValues`.
- [x] `id.test.ts`, `id.md`, `index.ts`.
## 6. Schema (`src/schema/`)
- [x] `analytics-event-schema.ts` — `AnalyticsEventSchema` + `validateAnalyticsEvent`.
- [x] `analytics-event-schema.test.ts`, `analytics-event-schema.md`, `index.ts`.
## 7. Consent (`src/consent/`)
- [x] `consent.ts` — `ConsentPolicy`, `AllowAllPolicy`, `DoNotTrackPolicy`.
- [x] `consent.test.ts`, `consent.md`, `index.ts`.
## 8. PII (`src/pii/`)
- [x] `pii.ts` — `scrubPII` with `redact` / `hash` / `drop` strategies (dotted paths).
- [x] `pii.test.ts`, `pii.md`, `index.ts`.
## 9. Sampling (`src/sampling/`)
- [x] `sampling.ts` — `Sampler`, `HeadSampler` with per-event overrides.
- [x] `sampling.test.ts`, `sampling.md`, `index.ts`.
## 10. Storage backends (`src/storage/`)
- [x] `storage.ts` — `StorageBackend` interface, `MemoryStorageBackend`, `WebStorageBackend` (`maxBytes` + `QUEUE_FULL`).
- [x] `storage.test.ts`, `storage.md`, `index.ts`.
## 11. Session (`src/session/`)
- [x] `session.ts` — `SessionManager` (id / pageviews / duration / referrer / inactivity / renew / end).
- [x] `session.test.ts`, `session.md`, `index.ts`.
## 12. Transports (`src/transport/`)
- [x] `batch.ts` — `BatchTransport` (AbortController + `timeoutMs` + exponential backoff + 4xx short-circuit).
- [x] `beacon.ts` — `BeaconTransport` (`sendBeacon` + `fetch keepalive` fallback).
- [x] `batch.test.ts`, `batch.md`, `beacon.test.ts`, `beacon.md`, `index.ts`.
## 13. Tracker (`src/tracker/`)
- [x] `tracker.ts` — `AnalyticsTracker` consuming `EventBus<AnalyticsEventMap>`; consent → sampling → PII → enqueue + storage round-trip; auto-flush via injectable `TimersLike`; `restore()`; `identify`; `setProperties`; `setSessionId`; `flush()`; `shutdown()`.
- [x] `tracker.test.ts`, `tracker.md`, `index.ts`.
## 14. Telemetry (`src/telemetry/`)
- [x] `telemetry.ts` — `eventsTrackedCounter`, `eventsFlushedCounter`, `eventsDroppedCounter`, `batchSizeHistogram`, `flushLatencyHistogram` + `record*` helpers.
- [x] `telemetry.test.ts`, `telemetry.md`, `index.ts`.
## 15. Barrel (`src/index.ts`)
- [x] Explicit named re-exports — no `export *` per §6a.
## 16. Tests setup + integration
- [x] `tests/setup/polyfills.ts` — web-crypto + OTel `InMemoryMetricExporter`.
- [x] `tests/integration.test.ts` — tracker → batch transport → in-process endpoint; "no `node:` imports in src/".
## 17. Docs
- [x] `README.md` — overview, subpath table, consent / PII / sampling / storage guides, migration notes from upstream `EventEmitter` → `EventBus`.
## 18. Verify
- [x] `bunx turbo lint typecheck test build --filter=@suaveplan/analytics-browser` — green.
- [x] `bunx turbo test:coverage --filter=@suaveplan/analytics-browser` — 100/100/100/100.
- [x] `bun scripts/folder-reorg/generate-manifest.ts` (refresh `MANIFEST.md`).
- [x] No `@ts-expect-error`, `any`, `console.log`, `vi.mock`, `vi.stubGlobal` in src/.
## 19. Fold + archive
- [x] Fold `specs/analytics-browser/spec.md` into `openspec/specs/analytics-browser/spec.md`.
- [x] Move folder to `openspec/archive/2026-05-25-enterprise-analytics-browser/`.
## 20. STATUS
- [x] Flip `@suaveplan/analytics-browser` `–` → `✅` in `STATUS.md`; bump migrated count 283 → 284, pending 210 → 209.