Initial Commit
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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"`
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user