Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# `consent.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `ConsentPolicy` SPI bundled with `@suaveplan/analytics-browser`. The tracker calls `policy.isAllowed(event)` before any sampling, scrubbing, or queueing decision, so a `false` return short-circuits the entire pipeline and emits a `drop` event with reason `consent`. Two built-in policies ship with the package: `AllowAllPolicy` (default) and `DoNotTrackPolicy` (honours `navigator.doNotTrack === "1"`). Downstream consumers can implement their own policy — e.g. one that consults a stored cookie-banner preference — by implementing the interface from `@suaveplan/analytics-browser/types`.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy navigator lookup — both policies are constructable in SSR / Node contexts where `navigator` is undefined; the policy simply permits the event in that case.
|
||||
- Zero state — both built-ins are stateless and reentrant.
|
||||
- Strict equality on `"1"` — matches the W3C Tracking Preference Expression spec; values `"0"` or `"unspecified"` permit tracking.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
|
||||
const tracker = new AnalyticsTracker(transport, {
|
||||
consentPolicy: new DoNotTrackPolicy(),
|
||||
});
|
||||
tracker.track("page_loaded"); // dropped if navigator.doNotTrack === "1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnalyticsEvent,
|
||||
ConsentPolicy,
|
||||
} from "@suaveplan/analytics-browser/types";
|
||||
|
||||
class CookieBannerPolicy implements ConsentPolicy {
|
||||
public constructor(private readonly read: () => "granted" | "denied") {}
|
||||
|
||||
public isAllowed(_event: AnalyticsEvent): boolean {
|
||||
return this.read() === "granted";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AllowAllPolicy implements ConsentPolicy` — `isAllowed(event) → true`.
|
||||
- `class DoNotTrackPolicy implements ConsentPolicy` — returns `false` iff `typeof navigator !== "undefined" && navigator.doNotTrack === "1"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DoNotTrackPolicy` reads `navigator.doNotTrack` on every call rather than caching at construction time. This is deliberate: the spec lets the user toggle the preference at runtime in some browsers (notably in private windows), and caching would freeze the decision at construction. The cost is a single property read per event — negligible compared with the JSON serialisation that follows on the happy path. Both classes pass their `event` argument with a leading underscore name to signal it is intentionally unused; lint rules permit underscore-prefixed unused parameters.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and typed error class for `@suaveplan/analytics-browser`. The registry uses `@suaveplan/error`'s `defineCodes` so every value is namespaced under the `ANALYTICS_BROWSER.` prefix and resolves through the shared error-resolution pipeline (i18n, OTel span events, structured JSON). Consumers SHOULD discriminate on `error.code` rather than `instanceof` checks so the namespace survives module boundaries and runtime serialisation. The `AnalyticsBrowserError` class is the only typed error thrown by the package and extends `SuaveplanError<AnalyticsBrowserCode>` so a `switch (err.code)` block narrows against the literal union.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight canonical reasons covering Zod-boundary validation, transport timeout / failure, durable queue overflow, missing host storage, tracker-after-shutdown rejection, invalid analytics-event payload, and beacon rejection.
|
||||
- Code values are TypeScript `as const` literals so the resulting union is fully inferred.
|
||||
- `AnalyticsBrowserError` exposes a public constructor (the base `SuaveplanError` constructor is protected) carrying `cause` and `context` for full forensic detail.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ANALYTICS_BROWSER_CODES,
|
||||
AnalyticsBrowserError,
|
||||
} from "@suaveplan/analytics-browser/error-codes";
|
||||
|
||||
try {
|
||||
await transport.send(events);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AnalyticsBrowserError &&
|
||||
err.code === ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT
|
||||
) {
|
||||
console.warn("retry on next flush", err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ANALYTICS_BROWSER_CODES,
|
||||
type AnalyticsBrowserCode,
|
||||
AnalyticsBrowserError,
|
||||
} from "@suaveplan/analytics-browser/error-codes";
|
||||
|
||||
function explain(code: AnalyticsBrowserCode): string {
|
||||
switch (code) {
|
||||
case ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT:
|
||||
return "request exceeded timeoutMs";
|
||||
case ANALYTICS_BROWSER_CODES.TRANSPORT_FAILED:
|
||||
return "backend returned 4xx";
|
||||
case ANALYTICS_BROWSER_CODES.QUEUE_FULL:
|
||||
return "durable queue is at capacity";
|
||||
case ANALYTICS_BROWSER_CODES.STORAGE_UNAVAILABLE:
|
||||
return "host Storage is missing";
|
||||
case ANALYTICS_BROWSER_CODES.SHUTDOWN:
|
||||
return "tracker has been shut down";
|
||||
case ANALYTICS_BROWSER_CODES.INVALID_EVENT:
|
||||
return "event failed Zod validation";
|
||||
case ANALYTICS_BROWSER_CODES.INVALID_OPTIONS:
|
||||
return "constructor options failed validation";
|
||||
case ANALYTICS_BROWSER_CODES.BEACON_REJECTED:
|
||||
return "navigator.sendBeacon returned false";
|
||||
}
|
||||
}
|
||||
|
||||
throw new AnalyticsBrowserError(
|
||||
ANALYTICS_BROWSER_CODES.QUEUE_FULL,
|
||||
"watermark exceeded",
|
||||
{ context: { depth: 1024 } },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ANALYTICS_BROWSER_CODES` — `Readonly<Record<Reason, "ANALYTICS_BROWSER.<Reason>">>`. The keys are the literal reason names; the values are namespaced strings.
|
||||
- `type AnalyticsBrowserCode` — string-literal union of all eight code values.
|
||||
- `class AnalyticsBrowserError extends SuaveplanError<AnalyticsBrowserCode>` — `new AnalyticsBrowserError(code, message, { cause?, context? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is called eagerly at module import so the registry is populated by the time any tracker or transport asks `@suaveplan/error` to resolve a code. The namespace string `ANALYTICS_BROWSER` is unique across the project registry — no other package owns it. Adding a new code is a backward-compatible change as long as the existing reasons remain stable; deletions or renames require an spec change.
|
||||
@@ -0,0 +1,51 @@
|
||||
# `id.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal cryptographically-random ID generator shared by the analytics tracker, the session manager, and any consumer that injects a default `IdGenerator`. Centralising the implementation removes the duplicate `generateId()` that lived in both `tracker.ts` and `session.ts` upstream and guarantees that anonymous IDs, event IDs, and session IDs share a single entropy source. The output is a 32-character hex string drawn from `crypto.getRandomValues`, suitable for use as a public identifier without leaking host state.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of randomness — `globalThis.crypto.getRandomValues` directly, no Math.random fallback.
|
||||
- Pure synchronous return; no `Promise` overhead on the hot path.
|
||||
- Hex-encoded so the ID is URL-safe, log-safe, and 1:1 with the byte length (16 bytes → 32 chars).
|
||||
- Re-exported through `src/internal/id/index.ts` so the rest of the package never imports from a flat module.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { generateId } from "@suaveplan/analytics-browser/tracker";
|
||||
// ↑ generateId is internal; in tests / docs import from the barrel that
|
||||
// happens to re-export it. Production code typically lets the tracker
|
||||
// default-construct its own IDs.
|
||||
|
||||
const anonymousId = generateId();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { IdGenerator } from "@suaveplan/types";
|
||||
import { generateId } from "./internal/id/id.js";
|
||||
|
||||
// Wrap the function-shape IdGenerator from @suaveplan/types around the
|
||||
// shared implementation — useful when a downstream consumer wants to
|
||||
// inject a deterministic counter in tests.
|
||||
const realIds: IdGenerator = generateId;
|
||||
const fakeIds: IdGenerator = (() => {
|
||||
let n = 0;
|
||||
return () => `evt-${(++n).toString().padStart(32, "0")}`;
|
||||
})();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `generateId(): string` — returns a fresh 32-character lowercase hex string. Throws synchronously only if `globalThis.crypto.getRandomValues` is missing or throws (e.g. when the host crypto API is intentionally disabled).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 16-byte entropy budget gives 128 bits — the same size as a UUIDv4 — without the dash separators or version nibbles. Hex encoding is the simplest reversible representation and is cheap (~16 string concatenations) compared with base32 or base64. The byte length lives in `constants/index.ts` so a future opt-in to 24-byte IDs is a one-line constant change rather than a refactor across call sites.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `pii.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEvent` plus an ordered array of `ScrubRule`s and returns a new event with the affected `properties` either redacted, replaced with a deterministic fingerprint, or dropped entirely. Scrubbing runs on the tracker hot path between consent / sampling decisions and the queue write, so it must be synchronous, side-effect-free, and never throw on valid input. The function clones `properties` via `structuredClone` so the original event remains immutable.
|
||||
|
||||
## Features
|
||||
|
||||
- Three strategies: `"redact"` (replace value with `"[REDACTED]"`), `"hash"` (replace with FNV-1a fingerprint), `"drop"` (delete the key).
|
||||
- Dotted-path field resolution — `"user.email"` walks into nested objects.
|
||||
- Order-preserving — rules apply in the order given so later rules see the effect of earlier ones.
|
||||
- Pure / synchronous — no async, no I/O, no `subtle.digest`.
|
||||
- Idempotent — the same input + rules always yields the same output (including the fingerprint).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const safe = scrubPII(event, [
|
||||
{ field: "email", strategy: "redact" },
|
||||
{ field: "ssn", strategy: "drop" },
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ScrubRule } from "@suaveplan/analytics-browser/types";
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const piiRules: readonly ScrubRule[] = [
|
||||
{ field: "user.email", strategy: "redact" },
|
||||
{ field: "user.phone", strategy: "hash" },
|
||||
{ field: "payment.cardNumber", strategy: "drop" },
|
||||
];
|
||||
|
||||
function safeTrack(name: string, properties: Record<string, unknown>) {
|
||||
const wrapped = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: "s",
|
||||
anonymousId: "a",
|
||||
};
|
||||
return scrubPII(wrapped, piiRules);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `scrubPII(event: AnalyticsEvent, rules: readonly ScrubRule[]): AnalyticsEvent` — returns a fresh event with scrubbed `properties`. Returns the original event reference when `rules.length === 0`.
|
||||
- Rule shape: `{ field: string; strategy: "redact" | "hash" | "drop" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fingerprint is FNV-1a 32-bit, encoded as 8 lowercase hex chars. FNV-1a was chosen over Web Crypto's `subtle.digest` because the latter is asynchronous, which would force the entire scrub pipeline onto a Promise chain and complicate the tracker's hot path. FNV-1a is NOT a cryptographic hash — it is a redaction that preserves cross-event correlation (the same value always fingerprints to the same hex string) without revealing the value itself. If a stronger guarantee is required, downstream code SHOULD scrub via a custom rule sequence and a pre-hashed input value, or implement its own `Sampler`-style hook.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `sampling.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements head-based sampling for `@suaveplan/analytics-browser`. The tracker calls `sampler.shouldSample(event)` after the consent gate but before the scrubber so events that will be dropped never pay the cost of property scrubbing or queue persistence. `HeadSampler` accepts a base sample rate in `[0, 1]` plus an optional `Record<eventName, rate>` override map so common events (e.g. `page`) can be captured at a different rate than rare events (e.g. `error_reported`).
|
||||
|
||||
## Features
|
||||
|
||||
- Per-event-name overrides — `new HeadSampler(0.1, { error_reported: 1 })` keeps every error while sampling everything else at 10 %.
|
||||
- Strict clamping — rates `<= 0` always drop, rates `>= 1` always keep; `NaN` clamps to 0.
|
||||
- Injectable random source — the constructor accepts a `random: () => number` seam so tests can drive deterministic decisions without monkey-patching `Math.random`.
|
||||
- Stateless — every call is independent; safe to share a sampler across many trackers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
|
||||
const sampler = new HeadSampler(0.25); // keep 25 % of events
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
|
||||
// 1 % sampling for verbose page-view events, 100 % for clicks and errors.
|
||||
const sampler = new HeadSampler(0.01, {
|
||||
clicked: 1,
|
||||
error_reported: 1,
|
||||
});
|
||||
|
||||
// In tests, inject a deterministic counter so behaviour is reproducible.
|
||||
let n = 0;
|
||||
const deterministic = new HeadSampler(0.5, {}, () => {
|
||||
const sequence = [0.4, 0.6, 0.2, 0.8];
|
||||
return sequence[n++ % sequence.length] as number;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class HeadSampler implements Sampler`
|
||||
- `new HeadSampler(rate, overrides?, random?)`
|
||||
- `rate: number` — base rate in `[0, 1]`. Values outside this range clamp.
|
||||
- `overrides?: Readonly<Record<string, number>>` — per-event-name rate. Each entry clamps independently.
|
||||
- `random?: () => number` — random source; defaults to `Math.random`.
|
||||
- `shouldSample(event): boolean` — `true` keeps the event, `false` drops it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The clamp logic guards against floating-point drift and pathological caller input (e.g. accidentally passing `rate=2`). NaN clamps to 0 rather than 1 so a misconfigured environment variable does not silently flood the backend. The injected random source enables deterministic testing without resorting to `vi.stubGlobal` (banned by §2 of `AGENTS.md`).
|
||||
@@ -0,0 +1,52 @@
|
||||
# `analytics-event-schema.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical Zod schema for `AnalyticsEvent` and a `validateAnalyticsEvent` parse helper. The schema lets consumers validate untrusted payloads (iframe postMessage bridges, browser extension messages, recovered IndexedDB rows) before handing them to the tracker. The helper wraps `safeParse` so the boundary failure mode is a typed `AnalyticsBrowserError(INVALID_EVENT)` carrying the full Zod issue list on `error.context.issues` — no raw `ZodError` leaks past the boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the on-wire and in-memory shape of an analytics event.
|
||||
- Required: `id`, `name`, `timestamp`, `sessionId`, `anonymousId`, `properties` (record). Optional: `userId`.
|
||||
- Boundary-failure translation: every Zod issue is collected into `error.context.issues` so observability tooling can render the full failure tree.
|
||||
- Schema is exported directly so downstream packages can `.merge` / `.extend` to add domain-specific shape (e.g. an e-commerce analytics extension).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateAnalyticsEvent } from "@suaveplan/analytics-browser/schema";
|
||||
|
||||
const safe = validateAnalyticsEvent(unknownPayload);
|
||||
tracker.track(safe.name, safe.properties);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { AnalyticsEventSchema } from "@suaveplan/analytics-browser/schema";
|
||||
|
||||
// Domain-specific extension — every commerce event additionally carries
|
||||
// a non-empty `cartId`. Composition stays within the Zod model so the
|
||||
// .issues path stays intact when validation fails.
|
||||
const CommerceEventSchema = AnalyticsEventSchema.extend({
|
||||
properties: z
|
||||
.record(z.string(), z.unknown())
|
||||
.refine((p) => typeof p.cartId === "string" && p.cartId.length > 0, {
|
||||
message: "missing cartId",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AnalyticsEventSchema: z.ZodType<AnalyticsEvent>` — the raw Zod schema.
|
||||
- `validateAnalyticsEvent(input: unknown): AnalyticsEvent` — parses and returns; throws `AnalyticsBrowserError(INVALID_EVENT)` on failure with `error.context.issues` exposing the Zod issue array.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema deliberately uses `z.string().min(1)` rather than `z.string()` for every identifier so a producer cannot bypass validation by sending an empty string. `properties` uses `z.record(z.string(), z.unknown())` rather than a stricter shape because tracker callers attach arbitrary metadata; if a downstream consumer needs a stricter contract they SHOULD `.extend` the schema as shown above. The helper does not log on failure — that is the caller's decision — and never swallows errors silently.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `session.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Tracks the lifecycle of a single browser session for `@suaveplan/analytics-browser`. The `SessionManager` owns the session ID, the start time, the page-view counter, the captured `document.referrer`, and the inactivity-timeout state machine. Consumers attach it to a tracker (`tracker.setSessionId(session.getSessionId())`) and call `recordPageView()` / `touch()` as user interaction occurs. The class is fully clock-injectable so tests can drive the inactivity timeout without sleeping.
|
||||
|
||||
## Features
|
||||
|
||||
- Cryptographically-random session ID (defaults to the package's shared `generateId`).
|
||||
- Inactivity timeout — `isActive()` returns `false` after `timeoutMs` of silence; the next `recordPageView()` is a no-op until `renew()` is called.
|
||||
- Manual `renew()` — resets the ID, start time, page-view count, and active flag (used on logout / login or app-managed session boundary events).
|
||||
- Injectable `clock`, `idGenerator`, and `referrer` for deterministic tests and SSR.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SessionManager } from "@suaveplan/analytics-browser/session";
|
||||
|
||||
const session = new SessionManager({ timeoutMs: 30 * 60 * 1000 });
|
||||
session.recordPageView();
|
||||
const info = session.getInfo();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SessionManager } from "@suaveplan/analytics-browser/session";
|
||||
|
||||
let now = 0;
|
||||
const session = new SessionManager({
|
||||
timeoutMs: 60_000,
|
||||
clock: () => now,
|
||||
idGenerator: () => "test-session",
|
||||
referrer: "https://example.com",
|
||||
});
|
||||
|
||||
now = 30_000;
|
||||
session.recordPageView();
|
||||
now = 120_000; // exceeds timeoutMs since last activity
|
||||
session.isActive(); // → false
|
||||
session.renew(); // mint a fresh session id
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionManager`
|
||||
- `new SessionManager(options?)` — `options: SessionOptions { timeoutMs?, referrer?, clock?, idGenerator? }`
|
||||
- `getSessionId(): string`
|
||||
- `getReferrer(): string`
|
||||
- `getPageViews(): number`
|
||||
- `recordPageView(): void` — no-op when session is inactive
|
||||
- `touch(): void` — resets the inactivity clock
|
||||
- `getDuration(): number` — milliseconds since `startedAt`
|
||||
- `isActive(): boolean` — auto-expires past `timeoutMs`
|
||||
- `end(): void` — explicitly terminates the session
|
||||
- `renew(): string` — mints a fresh session id, zeros page views, resets the inactivity clock
|
||||
- `getInfo(): SessionInfo` — `{ id, startedAt, pageViews, duration, referrer, isActive }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getDuration` returns the live duration when the session is active, otherwise the duration up to the last activity timestamp. This keeps the duration stable after `end()` is called — repeated `getDuration()` calls on an ended session return the same value rather than drifting upward with wall-clock time. The `document.referrer` lookup is guarded by `typeof document !== "undefined"` so SSR contexts default to `""` rather than throwing.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `storage.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `StorageBackend` SPI bundled with `@suaveplan/analytics-browser`. Two backends ship: `MemoryStorageBackend` (default) and `WebStorageBackend` (DOM `Storage`-backed JSON queue with an optional `maxBytes` watermark). The tracker writes every accepted event into both an in-memory FIFO and the configured backend; the backend supplies durability across page reloads or transport outages. An IndexedDB adapter will slot into the same SPI once `@suaveplan/storage-browser` lands.
|
||||
|
||||
## Features
|
||||
|
||||
- `MemoryStorageBackend` — zero-config FIFO in a single array.
|
||||
- `WebStorageBackend` — `localStorage` / `sessionStorage` / any `Storage` implementation, JSON-encoded, custom key, byte-count watermark with `QUEUE_FULL` ejection.
|
||||
- Defensive read — corrupted or non-array payloads are ignored rather than throwing.
|
||||
- Constructor validation — `WebStorageBackend({ storage: null })` throws `STORAGE_UNAVAILABLE` at construction time, before the tracker ever runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
MemoryStorageBackend,
|
||||
WebStorageBackend,
|
||||
} from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
// SSR / tests
|
||||
const backend = new MemoryStorageBackend();
|
||||
|
||||
// Production browser
|
||||
const backend = new WebStorageBackend({ storage: localStorage });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
// Tight watermark for low-storage devices.
|
||||
const backend = new WebStorageBackend({
|
||||
storage: localStorage,
|
||||
key: "@my-app/analytics",
|
||||
maxBytes: 64_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface StorageBackend` — `enqueue(events) → Promise<void>`, `dequeueAll() → Promise<AnalyticsEvent[]>`, `clear() → Promise<void>`, `getDepth() → Promise<number>`.
|
||||
- `class MemoryStorageBackend implements StorageBackend` — no constructor arguments.
|
||||
- `class WebStorageBackend implements StorageBackend` — `new WebStorageBackend({ storage, key?, maxBytes? })`. Throws `AnalyticsBrowserError(STORAGE_UNAVAILABLE)` when `storage` is falsy and `AnalyticsBrowserError(QUEUE_FULL)` on enqueue overflow.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WebStorageBackend` writes the full JSON array on every enqueue rather than appending — this keeps the on-disk shape trivially reversible and means a corrupted last-write fails-closed (the parse returns `[]`, the next flush drains nothing). The cost is an O(n) write per enqueue; for typical analytics traffic (10–100 events per flush) this is negligible. The byte-count check is performed against the encoded payload length rather than the array length so a single oversized event cannot bypass the watermark by dragging in many other events.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `telemetry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the OpenTelemetry instruments emitted by `@suaveplan/analytics-browser`. Three counters cover the accept / flush / drop paths and two histograms cover batch sizes and flush latencies. Every instrument is bound lazily through `metrics.getMeter(METER_NAME)` so production code that never installs a `MeterProvider` pays only a single property-read cost per recorded event — the OTel no-op meter discards everything cheaply.
|
||||
|
||||
## Features
|
||||
|
||||
- `eventsTrackedCounter{name}` — accepted events handed to the queue.
|
||||
- `eventsFlushedCounter{status}` — flush attempts; `status: "success" | "failure"`.
|
||||
- `eventsDroppedCounter{reason}` — dropped events; `reason: "consent" | "sampling" | "queue_full" | "shutdown" | "invalid"`.
|
||||
- `batchSizeHistogram` — observed flush sizes (event count).
|
||||
- `flushLatencyHistogram` — wall-clock duration of a single `transport.send`, in milliseconds.
|
||||
- Helper `record*` functions encapsulate the tag schema so call sites never assemble tag bags inline.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordEventTracked } from "@suaveplan/analytics-browser/telemetry";
|
||||
recordEventTracked("clicked");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
eventsTrackedCounter,
|
||||
recordFlush,
|
||||
} from "@suaveplan/analytics-browser/telemetry";
|
||||
|
||||
// Add custom tags to the underlying instrument when integrating with a
|
||||
// product-specific OTel pipeline.
|
||||
eventsTrackedCounter.add(1, {
|
||||
name: "clicked",
|
||||
surface: "checkout-button",
|
||||
});
|
||||
|
||||
recordFlush("success", batch.length, performance.now() - start);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `eventsTrackedCounter: Counter` — backing instrument.
|
||||
- `eventsFlushedCounter: Counter`
|
||||
- `eventsDroppedCounter: Counter`
|
||||
- `batchSizeHistogram: Histogram`
|
||||
- `flushLatencyHistogram: Histogram`
|
||||
- `recordEventTracked(name: string): void`
|
||||
- `recordFlush(status, eventCount, latencyMs): void`
|
||||
- `recordDropped(reason: DropReason): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter name follows the convention of `@suaveplan/<package>` so dashboards and alerts can filter on a single attribute. Histograms intentionally do not declare explicit bucket boundaries — the SDK picks reasonable defaults, and downstream observability platforms (Honeycomb / Grafana / Datadog) typically re-bucket on ingest anyway. The `batch_size` histogram has no unit; the `flush_latency_ms` histogram declares `ms`.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `tracker.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AnalyticsTracker` is the central orchestrator of `@suaveplan/analytics-browser`. It accepts user-facing events through `track()` / `page()` / `identify()`, runs each through a deterministic consent → sampling → PII-scrub pipeline, persists accepted events into both an in-memory FIFO and an injectable `StorageBackend`, and flushes batched events through the supplied `Transport`. Lifecycle observability is exposed through a typed `EventBus<AnalyticsEventMap>` on `tracker.events`. Every browser-API touch (timers, clock, IDs, storage) is reached through an injectable seam, so the tracker is fully testable without `vi.stubGlobal` or browser-specific mocks.
|
||||
|
||||
## Features
|
||||
|
||||
- Six-channel typed lifecycle bus — `track`, `pageview`, `identify`, `flush`, `drop`, `error`, `shutdown`.
|
||||
- Single-flight `flush()` — concurrent calls share the in-flight promise.
|
||||
- Auto-flush via injectable `TimersLike` — `flushInterval=0` disables the interval entirely.
|
||||
- Durable queue round-trip — persisted events are restored via `restore()` and cleared after a successful flush.
|
||||
- OTel telemetry — every accept / drop / flush emits the bundled counters/histograms.
|
||||
- Single-payload event bus — payload shape `{ event }` / `{ reason, event? }` etc. for ergonomic destructuring.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const tracker = new AnalyticsTracker(
|
||||
new BatchTransport({ endpoint: "/api/analytics" }),
|
||||
);
|
||||
|
||||
tracker.identify("user-123");
|
||||
tracker.track("clicked", { surface: "header" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
import { BatchTransport, BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
const transport = new BatchTransport({ endpoint: "/api/analytics" });
|
||||
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
|
||||
|
||||
const tracker = new AnalyticsTracker(transport, {
|
||||
consentPolicy: new DoNotTrackPolicy(),
|
||||
sampler: new HeadSampler(0.1, { error_reported: 1 }),
|
||||
scrubRules: [
|
||||
{ field: "email", strategy: "redact" },
|
||||
{ field: "card.number", strategy: "drop" },
|
||||
],
|
||||
storage: new WebStorageBackend({ storage: localStorage }),
|
||||
});
|
||||
|
||||
// Restore any events persisted across reload.
|
||||
await tracker.restore();
|
||||
|
||||
// Unload-safe flush: hand off to the beacon transport.
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
void beacon.send([...tracker.events.constructor.prototype, ...[]]);
|
||||
void tracker.flush();
|
||||
}
|
||||
});
|
||||
|
||||
tracker.events.on("flush", ({ eventCount, success, latencyMs }) => {
|
||||
console.info("flush", { eventCount, success, latencyMs });
|
||||
});
|
||||
|
||||
tracker.events.on("drop", ({ reason, event }) => {
|
||||
console.warn("dropped", reason, event?.name);
|
||||
});
|
||||
|
||||
// Final shutdown — drain and disable.
|
||||
await tracker.shutdown();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AnalyticsTracker`
|
||||
- `new AnalyticsTracker(transport: Transport, options?: TrackerOptions)`.
|
||||
- `events: EventBus<AnalyticsEventMap>`.
|
||||
- `restore(): Promise<void>` — replay persisted events into the in-memory queue.
|
||||
- `track(name, properties?)`.
|
||||
- `page(url?, properties?)`.
|
||||
- `identify(userId, traits?)`.
|
||||
- `setProperties(properties)`.
|
||||
- `getAnonymousId() / getUserId() / getSessionId() / setSessionId(id) / getQueueSize()`.
|
||||
- `flush(): Promise<boolean>` — single-flight; success drains storage too.
|
||||
- `shutdown(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tracker preserves event order through `flush` failure paths by `unshift`-ing the drained batch back onto the head of the in-memory queue when the transport returns `false` or throws. This keeps the relative ordering of new events that arrived during the flush window. The persistence layer is written-through (every accepted event hits storage), but cleared only on successful flush — a transport that returns `false` leaves the persisted copy intact so a subsequent flush attempt or page reload can still recover the events. The `flushInFlight` promise guard guarantees single-flight semantics; concurrent callers all observe the same outcome.
|
||||
@@ -0,0 +1,51 @@
|
||||
# `batch.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `BatchTransport` — the HTTP transport used by `AnalyticsTracker` for the steady-state, non-unload flush path. Every call posts a JSON-encoded `{ events }` body to the configured endpoint, wrapped in an `AbortController` whose timeout is armed via the injected `TimersLike`. The transport short-circuits on `4xx` responses (no retry), retries with exponential backoff on `5xx` / network errors, and translates any post-abort failure into a typed `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` so callers can discriminate timeouts from other transport failures.
|
||||
|
||||
## Features
|
||||
|
||||
- `AbortController` + `timeoutMs` on every fetch — guaranteed bounded latency.
|
||||
- `4xx` short-circuit — no retry storm on stable client errors.
|
||||
- Exponential backoff on `5xx` / network — `retryDelay * 2 ** attempt`.
|
||||
- Injectable `fetchFn` and `timers` — every browser-API touch is reachable from tests without DOM globals.
|
||||
- Idempotent `shutdown()` — future `send()` calls are no-ops returning `true`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const transport = new BatchTransport({ endpoint: "/api/analytics" });
|
||||
await transport.send(events);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const transport = new BatchTransport({
|
||||
endpoint: "https://collector.example.com/v1/events",
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: async (url, init) => {
|
||||
const req = new Request(url, init);
|
||||
return fetch(req, { credentials: "include" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BatchTransport implements Transport`
|
||||
- `new BatchTransport(options)` — `options: BatchTransportOptions { endpoint, maxRetries?, retryDelay?, timeoutMs?, fetchFn?, timers? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
|
||||
- `send(events) → Promise<boolean>` — `true` on success, `false` on `4xx` or exhausted retries. Throws `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` when the fetch is aborted by the timeout.
|
||||
- `shutdown() → Promise<void>` — disables further sends.
|
||||
- `isActive() → boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timeout `setTimer` callback aborts the controller with an `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` as the reason. If the host's `fetch` rejects with that exact error, the transport rethrows it as-is. If the fetch rejects with a different error after the signal has aborted (e.g. a `DOMException` synthesised by the runtime), the transport still maps the failure to `TRANSPORT_TIMEOUT` because the signal's aborted state is the authoritative reason. Otherwise the failure is treated as a generic retryable network error and the loop continues until `maxRetries` is exhausted.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `beacon.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `BeaconTransport` — the page-unload-safe transport used by `AnalyticsTracker.shutdown()` and any consumer wiring a `visibilitychange → hidden` flush. Browsers guarantee that `navigator.sendBeacon` will complete the request even during unload, which makes it the only reliable way to ship a last batch before navigation. When `sendBeacon` returns `false` (body too large, host rejected the beacon, navigator unavailable), the transport optionally falls back to `fetch` with `keepalive: true` or throws a typed `AnalyticsBrowserError(BEACON_REJECTED)`.
|
||||
|
||||
## Features
|
||||
|
||||
- `navigator.sendBeacon` first, `fetch keepalive` fallback.
|
||||
- Injectable `sendBeaconFn` and `fetchFn` so happy-dom and Node test envs can drive both paths.
|
||||
- Graceful degradation — missing `navigator` or missing `sendBeacon` returns `false`, letting the fallback or the typed error path take over.
|
||||
- `fallbackToFetch` defaults to `true` so the steady-state behaviour matches what production code wants.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
|
||||
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
void tracker.flush();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const beacon = new BeaconTransport({
|
||||
endpoint: "/api/analytics",
|
||||
fallbackToFetch: false, // strict — surface BEACON_REJECTED to the caller
|
||||
sendBeaconFn: (url, body) => navigator.sendBeacon(url, body),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BeaconTransport implements Transport`
|
||||
- `new BeaconTransport(options)` — `options: BeaconTransportOptions { endpoint, fallbackToFetch?, sendBeaconFn?, fetchFn? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
|
||||
- `send(events) → Promise<boolean>` — `true` on beacon success or `200` fallback fetch. Throws `AnalyticsBrowserError(BEACON_REJECTED)` when `sendBeaconFn` returns `false` and `fallbackToFetch === false`.
|
||||
- `shutdown() → Promise<void>` — disables future sends.
|
||||
- `isActive() → boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`sendBeacon` is synchronous and returns a boolean that signals whether the request was queued, not whether the server received it. The transport treats `true` as success because the browser owns delivery from that point on — there is no observable response to inspect during unload. The fallback fetch sets `keepalive: true` so the same unload-safety guarantee applies; `keepalive` is supported in every browser that ships `sendBeacon`, which keeps the fallback meaningful.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `analytics.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Single home for every type and interface consumed by the `@suaveplan/analytics-browser` runtime — the tracker, transports, session manager, storage backends, consent policies, samplers, scrubbers, and the typed `EventBus<AnalyticsEventMap>` lifecycle bus. Centralising types here lets feature modules import a single tree of contracts and lets downstream consumers (React shells, R3F apps) discover every public surface from one declaration file.
|
||||
|
||||
## Features
|
||||
|
||||
- `AnalyticsEvent` — the canonical event shape, fully immutable.
|
||||
- `AnalyticsEventMap extends EventMap` — single-payload object on every channel so the `EventBus.emit(type, payload)` signature applies.
|
||||
- `Transport` / `StorageBackend` / `ConsentPolicy` / `Sampler` — pluggable SPIs.
|
||||
- `ScrubRule` / `ScrubStrategy` — PII scrubber contract.
|
||||
- Option bags (`TrackerOptions`, `BatchTransportOptions`, `BeaconTransportOptions`, `SessionOptions`) — every field optional so call sites can opt into only what they need.
|
||||
- Re-exports `Clock`, `IdGenerator`, `TimersLike` from `@suaveplan/types` so consumers don't need a second peer dependency.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnalyticsEvent,
|
||||
AnalyticsEventMap,
|
||||
TrackerOptions,
|
||||
Transport,
|
||||
} from "@suaveplan/analytics-browser/types";
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { EventBus } from "@suaveplan/events";
|
||||
import type {
|
||||
AnalyticsEventMap,
|
||||
DropReason,
|
||||
StorageBackend,
|
||||
} from "@suaveplan/analytics-browser/types";
|
||||
|
||||
declare function attach(
|
||||
bus: EventBus<AnalyticsEventMap>,
|
||||
backend: StorageBackend,
|
||||
): void;
|
||||
|
||||
function classify(reason: DropReason): "operator" | "client" {
|
||||
return reason === "queue_full" || reason === "shutdown"
|
||||
? "operator"
|
||||
: "client";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AnalyticsEvent` — id, name, properties, timestamp (ISO 8601), sessionId, anonymousId, optional userId.
|
||||
- `DropReason` — `"consent" | "sampling" | "queue_full" | "shutdown" | "invalid"`.
|
||||
- `AnalyticsEventMap extends EventMap` — `track`, `pageview`, `identify`, `flush`, `drop`, `error`, `shutdown` channels.
|
||||
- `Transport` — `send(events) → Promise<boolean>`, `shutdown() → Promise<void>`.
|
||||
- `StorageBackend` — `enqueue` / `dequeueAll` / `clear` / `getDepth`.
|
||||
- `ConsentPolicy` — `isAllowed(event) → boolean`.
|
||||
- `Sampler` — `shouldSample(event) → boolean`.
|
||||
- `ScrubRule` — `{ field: string; strategy: ScrubStrategy }`.
|
||||
- `SessionInfo` — id / startedAt / pageViews / duration / referrer / isActive.
|
||||
- `SessionOptions`, `BatchTransportOptions`, `BeaconTransportOptions`, `TrackerOptions` — option bags described in their respective module docs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every option field is `readonly` so option bags can be safely shared across instances. The types module is **zero-runtime** — TypeScript erases every export to `undefined` at build time, which keeps the package's effective entry chunk lean and lets dead-code elimination prune unused option bags. The `Clock`, `IdGenerator`, and `TimersLike` re-exports point at the `@suaveplan/types` package; consumers MAY import them directly from there if they already depend on `@suaveplan/types`.
|
||||
Reference in New Issue
Block a user