Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
# @suaveplan/analytics-browser - Browser-tier client-side product analytics: AnalyticsTracker / BatchTransport / BeaconTransport / SessionManager with consent gate, PII scrubber, head-sampling, durable StorageBackend (memory + Web Storage), Zod-validated events, AbortController + timeoutMs on every fetch, OTel counters + histograms, and a typed EventBus lifecycle bus on @suaveplan/events.
Browser-tier client-side product analytics — `AnalyticsTracker`, `BatchTransport`, `BeaconTransport`, and `SessionManager` with consent gate, PII scrubber, head sampling, durable storage, Zod-validated events, AbortController + timeoutMs on every fetch, and OpenTelemetry counters / histograms. Lifecycle observability is exposed through the `@suaveplan/events` `EventBus`.
<!-- begin:auto-docs -->
## Documentation tree
- [consent](./src/consent/consent.md) — Implements the `ConsentPolicy` SPI bundled with `@suaveplan/analytics-browser`. The tracker calls `policy.isAllowed(eve…
- [consent](./src/consent/consent.md) — Implements the `ConsentPolicy` SPI bundled with `@suaveplan/analytics-browser`. The tracker calls `policy.isAllowed(eve…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/analytics-browser`. The registry uses `@su…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed error class for `@suaveplan/analytics-browser`. The registry uses `@su…
- [id](./src/internal/id/id.md) — Internal cryptographically-random ID generator shared by the analytics tracker, the session manager, and any consumer t…
- [id](./src/internal/id/id.md) — Internal cryptographically-random ID generator shared by the analytics tracker, the session manager, and any consumer t…
- [pii](./src/pii/pii.md) — Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEv…
- [pii](./src/pii/pii.md) — Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEv…
- [sampling](./src/sampling/sampling.md) — Implements head-based sampling for `@suaveplan/analytics-browser`. The tracker calls `sampler.shouldSample(event)` afte…
- [sampling](./src/sampling/sampling.md) — Implements head-based sampling for `@suaveplan/analytics-browser`. The tracker calls `sampler.shouldSample(event)` afte…
- [schema](./src/schema/analytics-event-schema.md) — Defines the canonical Zod schema for `AnalyticsEvent` and a `validateAnalyticsEvent` parse helper. The schema lets cons…
- [analytics-event-schema](./src/schema/analytics-event-schema.md) — Defines the canonical Zod schema for `AnalyticsEvent` and a `validateAnalyticsEvent` parse helper. The schema lets cons…
- [session](./src/session/session.md) — Tracks the lifecycle of a single browser session for `@suaveplan/analytics-browser`. The `SessionManager` owns the sess…
- [session](./src/session/session.md) — Tracks the lifecycle of a single browser session for `@suaveplan/analytics-browser`. The `SessionManager` owns the sess…
- [storage](./src/storage/storage.md) — Implements the `StorageBackend` SPI bundled with `@suaveplan/analytics-browser`. Two backends ship: `MemoryStorageBacke…
- [storage](./src/storage/storage.md) — Implements the `StorageBackend` SPI bundled with `@suaveplan/analytics-browser`. Two backends ship: `MemoryStorageBacke…
- [telemetry](./src/telemetry/telemetry.md) — Defines the OpenTelemetry instruments emitted by `@suaveplan/analytics-browser`. Three counters cover the accept / flus…
- [telemetry](./src/telemetry/telemetry.md) — Defines the OpenTelemetry instruments emitted by `@suaveplan/analytics-browser`. Three counters cover the accept / flus…
- [tracker](./src/tracker/tracker.md) — `AnalyticsTracker` is the central orchestrator of `@suaveplan/analytics-browser`. It accepts user-facing events through…
- [tracker](./src/tracker/tracker.md) — `AnalyticsTracker` is the central orchestrator of `@suaveplan/analytics-browser`. It accepts user-facing events through…
- [batch](./src/transport/batch.md) — Implements `BatchTransport` — the HTTP transport used by `AnalyticsTracker` for the steady-state, non-unload flush path…
- [beacon](./src/transport/beacon.md) — Implements `BeaconTransport` — the page-unload-safe transport used by `AnalyticsTracker.shutdown()` and any consumer wi…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/analytics-browser
```
## Quick start
```ts
import { AnalyticsTracker, BatchTransport } from "@suaveplan/analytics-browser";
const tracker = new AnalyticsTracker(
new BatchTransport({ endpoint: "/api/analytics" }),
);
tracker.identify("user-123");
tracker.track("clicked", { surface: "header" });
await tracker.flush();
```
## Subpath exports
| Path | Contents |
|---|---|
| `@suaveplan/analytics-browser` | Everything (single barrel). |
| `@suaveplan/analytics-browser/tracker` | `AnalyticsTracker`. |
| `@suaveplan/analytics-browser/transport` | `BatchTransport`, `BeaconTransport`. |
| `@suaveplan/analytics-browser/session` | `SessionManager`. |
| `@suaveplan/analytics-browser/consent` | `AllowAllPolicy`, `DoNotTrackPolicy`. |
| `@suaveplan/analytics-browser/pii` | `scrubPII`. |
| `@suaveplan/analytics-browser/sampling` | `HeadSampler`. |
| `@suaveplan/analytics-browser/storage` | `MemoryStorageBackend`, `WebStorageBackend`. |
| `@suaveplan/analytics-browser/schema` | `AnalyticsEventSchema`, `validateAnalyticsEvent`. |
| `@suaveplan/analytics-browser/error-codes` | `ANALYTICS_BROWSER_CODES`, `AnalyticsBrowserError`. |
| `@suaveplan/analytics-browser/telemetry` | OTel counters / histograms + helpers. |
| `@suaveplan/analytics-browser/types` | Type-only exports. |
## Consent gate
The tracker calls `consentPolicy.isAllowed(event)` before sampling, scrubbing, or queueing. Two built-ins ship:
- `AllowAllPolicy` — permits every event (default).
- `DoNotTrackPolicy` — honours `navigator.doNotTrack === "1"`.
```ts
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
const tracker = new AnalyticsTracker(transport, {
consentPolicy: new DoNotTrackPolicy(),
});
```
Implement `ConsentPolicy` yourself to honour a cookie banner / IAB TCF / regional toggle.
## PII scrubber
```ts
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
const tracker = new AnalyticsTracker(transport, {
scrubRules: [
{ field: "email", strategy: "redact" }, // → "[REDACTED]"
{ field: "phone", strategy: "hash" }, // → 8-char FNV-1a hex
{ field: "card.number", strategy: "drop" },
],
});
```
Dotted field paths walk into nested `properties` objects. The FNV-1a fingerprint is deterministic across calls — useful for cross-event correlation without revealing the value.
## Sampling
```ts
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
const sampler = new HeadSampler(0.1, {
error_reported: 1, // always keep errors
clicked: 1, // always keep clicks
});
```
`HeadSampler` clamps rates to `[0, 1]`; `NaN` clamps to `0`. The constructor accepts an injectable `random: () => number` so deterministic tests are trivial.
## Durable storage
```ts
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
const tracker = new AnalyticsTracker(transport, {
storage: new WebStorageBackend({
storage: localStorage,
key: "@my-app/analytics",
maxBytes: 1_048_576,
}),
});
// Restore events persisted across reload.
await tracker.restore();
```
The bundled backends are `MemoryStorageBackend` (default) and `WebStorageBackend`. An IndexedDB adapter will slot into the same SPI once `@suaveplan/storage-browser` lands; the contract (`enqueue` / `dequeueAll` / `clear` / `getDepth`) will not change.
## Unload-safe flushing
```ts
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
const beacon = new BeaconTransport({
endpoint: "/api/analytics",
fallbackToFetch: true,
});
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
void tracker.flush();
}
});
window.addEventListener("pagehide", () => {
void tracker.flush();
});
```
`BeaconTransport` uses `navigator.sendBeacon` first and optionally falls back to `fetch({ keepalive: true })`. When the host rejects the beacon and fallback is disabled, the transport throws `AnalyticsBrowserError(BEACON_REJECTED)`.
## Lifecycle event bus
```ts
tracker.events.on("track", ({ event }) => {});
tracker.events.on("flush", ({ eventCount, success, latencyMs }) => {});
tracker.events.on("drop", ({ reason, event }) => {});
tracker.events.on("error", ({ error, context }) => {});
tracker.events.on("shutdown", () => {});
```
The bus is a real `@suaveplan/events` `EventBus<AnalyticsEventMap>` — every payload is a single object (not a tuple).
## OpenTelemetry
`@suaveplan/analytics-browser` declares five OTel instruments under the meter name `@suaveplan/analytics-browser`:
| Instrument | Kind | Tags |
|---|---|---|
| `analytics_browser.events_tracked_total` | counter | `name` |
| `analytics_browser.events_flushed_total` | counter | `status` (`success` / `failure`) |
| `analytics_browser.events_dropped_total` | counter | `reason` (`consent` / `sampling` / `queue_full` / `shutdown` / `invalid`) |
| `analytics_browser.batch_size` | histogram | — |
| `analytics_browser.flush_latency_ms` | histogram | — (`ms`) |
No setup is required — the instruments are no-ops when no `MeterProvider` is registered.
## Migration from upstream `@suaveplan/analytics-browser@1.0.6`
The only behavioural change is the lifecycle bus shape. Upstream used `EventEmitter` with tuple payloads:
```ts
// upstream
tracker.events.on("track", (event) => {});
tracker.events.on("flush", (count, success) => {});
```
uses `EventBus` with single-object payloads:
```ts
// suaveplan
tracker.events.on("track", ({ event }) => {});
tracker.events.on("flush", ({ eventCount, success, latencyMs }) => {});
```
Constructor signature, public method names, and behavioural semantics of `track` / `identify` / `flush` / `shutdown` are preserved.

View File

@@ -0,0 +1,124 @@
{
"name": "@suaveplan/analytics-browser",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Browser-tier client-side product analytics: AnalyticsTracker / BatchTransport / BeaconTransport / SessionManager with consent gate, PII scrubber, head-sampling, durable StorageBackend (memory + Web Storage), Zod-validated events, AbortController + timeoutMs on every fetch, OTel counters + histograms, and a typed EventBus lifecycle bus on @suaveplan/events.",
"license": "MIT",
"author": "Suaveplan Team",
"browser": "./dist/index.js",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./tracker": {
"import": {
"types": "./dist/tracker/index.d.ts",
"default": "./dist/tracker/index.js"
}
},
"./transport": {
"import": {
"types": "./dist/transport/index.d.ts",
"default": "./dist/transport/index.js"
}
},
"./session": {
"import": {
"types": "./dist/session/index.d.ts",
"default": "./dist/session/index.js"
}
},
"./consent": {
"import": {
"types": "./dist/consent/index.d.ts",
"default": "./dist/consent/index.js"
}
},
"./pii": {
"import": {
"types": "./dist/pii/index.d.ts",
"default": "./dist/pii/index.js"
}
},
"./sampling": {
"import": {
"types": "./dist/sampling/index.d.ts",
"default": "./dist/sampling/index.js"
}
},
"./storage": {
"import": {
"types": "./dist/storage/index.d.ts",
"default": "./dist/storage/index.js"
}
},
"./schema": {
"import": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/analytics.types.d.ts",
"default": "./dist/types/analytics.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -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.

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { AllowAllPolicy, DoNotTrackPolicy } from "./consent.js";
const event: AnalyticsEvent = {
id: "i",
name: "n",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "a",
};
function setDnt(value: string | undefined): void {
if (typeof navigator === "undefined") {
return;
}
Object.defineProperty(navigator, "doNotTrack", {
configurable: true,
get: () => value,
});
}
afterEach(() => {
setDnt(undefined);
});
describe("AllowAllPolicy", () => {
it("permits every event", { timeout: 5000 }, () => {
const policy = new AllowAllPolicy();
expect(policy.isAllowed(event)).toBe(true);
});
});
describe("DoNotTrackPolicy", () => {
it("DoNotTrackPolicy blocks when navigator.doNotTrack === '1'", {
timeout: 5000,
}, () => {
setDnt("1");
const policy = new DoNotTrackPolicy();
expect(policy.isAllowed(event)).toBe(false);
});
it("permits events when DNT is not set", { timeout: 5000 }, () => {
setDnt("0");
const policy = new DoNotTrackPolicy();
expect(policy.isAllowed(event)).toBe(true);
});
it("permits events when navigator is not present (SSR)", {
timeout: 5000,
}, () => {
const realNav = globalThis.navigator;
try {
Object.defineProperty(globalThis, "navigator", {
value: undefined,
configurable: true,
});
const policy = new DoNotTrackPolicy();
expect(policy.isAllowed(event)).toBe(true);
} finally {
Object.defineProperty(globalThis, "navigator", {
value: realNav,
configurable: true,
});
}
});
});

View File

@@ -0,0 +1,29 @@
import type {
AnalyticsEvent,
ConsentPolicy,
} from "../types/analytics.types.js";
/**
* Permits every event. Default policy when none is supplied to the
* tracker.
*/
export class AllowAllPolicy implements ConsentPolicy {
public isAllowed(_event: AnalyticsEvent): boolean {
return true;
}
}
/**
* Honours the host's `Do Not Track` signal. Blocks every event when
* `navigator.doNotTrack === "1"`, otherwise permits every event. Reads
* the navigator lazily inside `isAllowed` so the policy is constructable
* in SSR / Node contexts where `navigator` does not exist.
*/
export class DoNotTrackPolicy implements ConsentPolicy {
public isAllowed(_event: AnalyticsEvent): boolean {
if (typeof navigator === "undefined") {
return true;
}
return navigator.doNotTrack !== "1";
}
}

View File

@@ -0,0 +1 @@
export { AllowAllPolicy, DoNotTrackPolicy } from "./consent.js";

View File

@@ -0,0 +1,15 @@
/**
* Default constants for `@suaveplan/analytics-browser`. These are
* intentionally simple and have no co-located docs (per CLAUDE.md
* §Package Structure → `constants/index.ts`).
*/
export const DEFAULT_BATCH_SIZE = 10;
export const DEFAULT_FLUSH_INTERVAL_MS = 30_000;
export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_RETRY_DELAY_MS = 1_000;
export const DEFAULT_TRANSPORT_TIMEOUT_MS = 10_000;
export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1_000;
export const DEFAULT_WEB_STORAGE_KEY = "@suaveplan/analytics-browser/queue";
export const DEFAULT_WEB_STORAGE_MAX_BYTES = 1_048_576;
export const ID_BYTE_LEN = 16;

View File

@@ -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.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "./error-codes.js";
describe("ANALYTICS_BROWSER_CODES", () => {
it("namespaces every code under ANALYTICS_BROWSER.", {
timeout: 5000,
}, () => {
for (const value of Object.values(ANALYTICS_BROWSER_CODES)) {
expect(value.startsWith("ANALYTICS_BROWSER.")).toBe(true);
}
});
it("exposes the expected leaf names", { timeout: 5000 }, () => {
expect(Object.keys(ANALYTICS_BROWSER_CODES).sort()).toEqual(
[
"BEACON_REJECTED",
"INVALID_EVENT",
"INVALID_OPTIONS",
"QUEUE_FULL",
"SHUTDOWN",
"STORAGE_UNAVAILABLE",
"TRANSPORT_FAILED",
"TRANSPORT_TIMEOUT",
].sort()
);
});
});
describe("AnalyticsBrowserError", () => {
it("carries the supplied code, message, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
"timeout",
{ cause, context: { endpoint: "/api" } }
);
expect(err).toBeInstanceOf(AnalyticsBrowserError);
expect(err.code).toBe(ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT);
expect(err.message).toBe("timeout");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ endpoint: "/api" });
expect(err.name).toBe("AnalyticsBrowserError");
});
it("works without options", { timeout: 5000 }, () => {
const err = new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.INVALID_OPTIONS,
"bad"
);
expect(err.code).toBe(ANALYTICS_BROWSER_CODES.INVALID_OPTIONS);
expect(err.message).toBe("bad");
});
});

View File

@@ -0,0 +1,50 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/analytics-browser`.
*
* Codes follow `ANALYTICS_BROWSER.<REASON>` and are resolved via
* `@suaveplan/error`'s `defineCodes`. Consumers SHOULD discriminate on
* `error.code` rather than `instanceof` so the namespace survives module
* boundaries and runtime serialisation.
*
* The registry covers Zod boundary failures on `validateAnalyticsEvent`
* (`INVALID_EVENT`) and `TrackerOptions` (`INVALID_OPTIONS`), every
* fetch-side transport failure (`TRANSPORT_TIMEOUT`, `TRANSPORT_FAILED`),
* durable-queue overflow (`QUEUE_FULL`), missing `Storage` host
* (`STORAGE_UNAVAILABLE`), tracker-after-shutdown rejection (`SHUTDOWN`),
* and beacon-rejected unload paths (`BEACON_REJECTED`).
*/
export const ANALYTICS_BROWSER_CODES = defineCodes("ANALYTICS_BROWSER", [
"INVALID_OPTIONS",
"TRANSPORT_TIMEOUT",
"TRANSPORT_FAILED",
"QUEUE_FULL",
"STORAGE_UNAVAILABLE",
"SHUTDOWN",
"INVALID_EVENT",
"BEACON_REJECTED",
] as const);
export type AnalyticsBrowserCode =
(typeof ANALYTICS_BROWSER_CODES)[keyof typeof ANALYTICS_BROWSER_CODES];
/**
* Typed error for every `@suaveplan/analytics-browser` failure path.
*
* Extends `SuaveplanError<AnalyticsBrowserCode>` so the `code` field
* narrows against the literal union and consumers get full type-checking
* on `switch (err.code)` blocks. Carries a public constructor so that
* `new AnalyticsBrowserError(...)` works at call sites —
* `SuaveplanError`'s own constructor is protected.
*/
export class AnalyticsBrowserError extends SuaveplanError<AnalyticsBrowserCode> {
public constructor(
code: AnalyticsBrowserCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "AnalyticsBrowserError";
}
}

View File

@@ -0,0 +1,5 @@
export type { AnalyticsBrowserCode } from "./error-codes.js";
export {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "./error-codes.js";

View File

@@ -0,0 +1,62 @@
// Errors
// Consent
export { AllowAllPolicy, DoNotTrackPolicy } from "./consent/consent.js";
export type { AnalyticsBrowserCode } from "./error-codes/error-codes.js";
export {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "./error-codes/error-codes.js";
// PII
export { scrubPII } from "./pii/pii.js";
// Sampling
export { HeadSampler } from "./sampling/sampling.js";
// Schema
export {
AnalyticsEventSchema,
validateAnalyticsEvent,
} from "./schema/analytics-event-schema.js";
// Session
export { SessionManager } from "./session/session.js";
// Storage
export {
MemoryStorageBackend,
WebStorageBackend,
type WebStorageBackendOptions,
} from "./storage/storage.js";
// Telemetry
export {
batchSizeHistogram,
eventsDroppedCounter,
eventsFlushedCounter,
eventsTrackedCounter,
flushLatencyHistogram,
recordDropped,
recordEventTracked,
recordFlush,
} from "./telemetry/telemetry.js";
// Tracker
export { AnalyticsTracker } from "./tracker/tracker.js";
// Transports
export { BatchTransport } from "./transport/batch.js";
export { BeaconTransport } from "./transport/beacon.js";
// Types
export type {
AnalyticsEvent,
AnalyticsEventMap,
BatchTransportOptions,
BeaconTransportOptions,
Clock,
ConsentPolicy,
DropReason,
IdGenerator,
Sampler,
ScrubRule,
ScrubStrategy,
SessionInfo,
SessionOptions,
StorageBackend,
TimersLike,
TrackerOptions,
Transport,
} from "./types/analytics.types.js";

View File

@@ -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.

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateId } from "./id.js";
describe("generateId", () => {
it("emits 32 hex chars", { timeout: 5000 }, () => {
const id = generateId();
expect(id).toMatch(/^[0-9a-f]{32}$/);
});
it("never repeats across consecutive calls", { timeout: 5000 }, () => {
const a = generateId();
const b = generateId();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,22 @@
import { ID_BYTE_LEN } from "../../constants/index.js";
/**
* Generate a 32-character hex ID drawn from `crypto.getRandomValues`.
*
* Used by `AnalyticsTracker` (anonymous + event ID), `SessionManager`
* (session ID), and the default `IdGenerator` plumbed into both. The
* function reads `globalThis.crypto.getRandomValues` directly — every
* browser, every test env polyfilled with `webcrypto`, and Node 19+
* exposes a compatible implementation. There is no Math.random fallback
* because analytics IDs need to be non-guessable.
*/
export function generateId(): string {
const bytes = new Uint8Array(ID_BYTE_LEN);
globalThis.crypto.getRandomValues(bytes);
let out = "";
for (let i = 0; i < bytes.length; i++) {
const value = bytes[i] as number;
out += value.toString(16).padStart(2, "0");
}
return out;
}

View File

@@ -0,0 +1 @@
export { generateId } from "./id.js";

View File

@@ -0,0 +1 @@
export { scrubPII } from "./pii.js";

View File

@@ -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.

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { scrubPII } from "./pii.js";
const base: AnalyticsEvent = {
id: "i",
name: "n",
properties: {
email: "a@b.co",
phone: "555-0100",
ssn: "123-45-6789",
user: { email: "nested@b.co", id: 7 },
},
timestamp: "t",
sessionId: "s",
anonymousId: "a",
};
describe("scrubPII", () => {
it("redact replaces value with [REDACTED]", { timeout: 5000 }, () => {
const out = scrubPII(base, [{ field: "email", strategy: "redact" }]);
expect(out.properties.email).toBe("[REDACTED]");
});
it("drop removes the field entirely", { timeout: 5000 }, () => {
const out = scrubPII(base, [{ field: "ssn", strategy: "drop" }]);
expect("ssn" in out.properties).toBe(false);
});
it("hash produces a deterministic fingerprint", { timeout: 5000 }, () => {
const a = scrubPII(base, [{ field: "phone", strategy: "hash" }]);
const b = scrubPII(base, [{ field: "phone", strategy: "hash" }]);
expect(a.properties.phone).toBe(b.properties.phone);
expect(a.properties.phone).not.toBe(base.properties.phone);
expect(typeof a.properties.phone).toBe("string");
expect((a.properties.phone as string).length).toBeGreaterThan(0);
});
it("dotted path resolves into nested objects", { timeout: 5000 }, () => {
const out = scrubPII(base, [
{ field: "user.email", strategy: "redact" },
]);
const nested = out.properties.user as { email: string };
expect(nested.email).toBe("[REDACTED]");
});
it("missing field is a no-op", { timeout: 5000 }, () => {
const out = scrubPII(base, [{ field: "missing", strategy: "drop" }]);
expect(out.properties).toEqual(base.properties);
});
it("returns the original event when no rules", { timeout: 5000 }, () => {
const out = scrubPII(base, []);
expect(out).toBe(base);
});
it("hashes objects via JSON stringification", { timeout: 5000 }, () => {
const out = scrubPII(base, [{ field: "user", strategy: "hash" }]);
expect(typeof out.properties.user).toBe("string");
});
it("dotted path with non-object segment is a no-op", {
timeout: 5000,
}, () => {
const out = scrubPII(base, [
{ field: "email.deeper", strategy: "redact" },
]);
expect(out.properties.email).toBe("a@b.co");
});
it("empty path is a no-op", { timeout: 5000 }, () => {
const out = scrubPII(base, [{ field: "", strategy: "redact" }]);
// splitting "" yields [""] — applyAt's last="" check resolves and
// operates on the "" key, which does not exist, so it is a no-op.
expect(out.properties).toEqual(base.properties);
});
it("does not mutate the source event", { timeout: 5000 }, () => {
const before = JSON.stringify(base.properties);
scrubPII(base, [{ field: "email", strategy: "drop" }]);
expect(JSON.stringify(base.properties)).toBe(before);
});
});

View File

@@ -0,0 +1,85 @@
import type { AnalyticsEvent, ScrubRule } from "../types/analytics.types.js";
const REDACTED = "[REDACTED]";
const FNV_OFFSET = 0x811c9dc5;
const FNV_PRIME = 0x01000193;
/**
* Deterministic 32-bit FNV-1a fingerprint of a value's stringified
* form. Used for the `"hash"` scrub strategy. FNV-1a is fast, has good
* avalanche behaviour for short strings, and is fully synchronous (Web
* Crypto's `subtle.digest` is async, which would force the entire scrub
* function into a Promise chain on the tracker hot path).
*
* The output is 8 lowercase hex chars; it is NOT a cryptographic hash
* and MUST NOT be relied on for security. It is a one-way redaction
* that preserves cross-event correlation (the same input always yields
* the same fingerprint).
*/
function fingerprint(value: unknown): string {
const text = typeof value === "string" ? value : JSON.stringify(value);
let h = FNV_OFFSET;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, FNV_PRIME);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
function applyAt(
properties: Record<string, unknown>,
path: readonly string[],
strategy: ScrubRule["strategy"]
): void {
// `scrubPII` always splits a non-empty `rule.field`, which produces a
// path with at least one element. The cast below documents the
// invariant without an unreachable guard.
const last = path[path.length - 1] as string;
let cursor: Record<string, unknown> = properties;
for (let i = 0; i < path.length - 1; i++) {
const segment = path[i] as string;
const next = cursor[segment];
if (next === null || typeof next !== "object") {
return;
}
cursor = next as Record<string, unknown>;
}
if (!(last in cursor)) {
return;
}
switch (strategy) {
case "redact": {
cursor[last] = REDACTED;
return;
}
case "drop": {
delete cursor[last];
return;
}
case "hash": {
cursor[last] = fingerprint(cursor[last]);
return;
}
}
}
/**
* Apply field-level scrub rules to an analytics event, returning a new
* event with the scrubbed properties. Rules apply in order; later rules
* see the effect of earlier rules (e.g. a `redact` followed by a
* `drop` will drop a missing key — a no-op).
*/
export function scrubPII(
event: AnalyticsEvent,
rules: readonly ScrubRule[]
): AnalyticsEvent {
if (rules.length === 0) {
return event;
}
const cloned: Record<string, unknown> = structuredClone(event.properties);
for (const rule of rules) {
const path = rule.field.split(".");
applyAt(cloned, path, rule.strategy);
}
return { ...event, properties: cloned };
}

View File

@@ -0,0 +1 @@
export { HeadSampler } from "./sampling.js";

View File

@@ -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`).

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { HeadSampler } from "./sampling.js";
function mkEvent(name: string): AnalyticsEvent {
return {
id: "i",
name,
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "a",
};
}
describe("HeadSampler", () => {
it("HeadSampler rate=0 drops every event", { timeout: 5000 }, () => {
const s = new HeadSampler(0);
expect(s.shouldSample(mkEvent("x"))).toBe(false);
});
it("HeadSampler rate=1 keeps every event", { timeout: 5000 }, () => {
const s = new HeadSampler(1);
expect(s.shouldSample(mkEvent("x"))).toBe(true);
});
it("per-event override beats base rate", { timeout: 5000 }, () => {
const s = new HeadSampler(0, { page: 1 });
expect(s.shouldSample(mkEvent("page"))).toBe(true);
expect(s.shouldSample(mkEvent("other"))).toBe(false);
});
it("draws against injected random for intermediate rates", {
timeout: 5000,
}, () => {
let n = 0;
const sequence = [0.05, 0.9];
const random = () => {
const value = sequence[n] as number;
n += 1;
return value;
};
const s = new HeadSampler(0.1, {}, random);
expect(s.shouldSample(mkEvent("x"))).toBe(true); // 0.05 < 0.1
expect(s.shouldSample(mkEvent("x"))).toBe(false); // 0.9 < 0.1 is false
});
it("clamps out-of-range rates", { timeout: 5000 }, () => {
const lo = new HeadSampler(-2);
const hi = new HeadSampler(2);
expect(lo.shouldSample(mkEvent("x"))).toBe(false);
expect(hi.shouldSample(mkEvent("x"))).toBe(true);
});
it("clamps NaN to 0", { timeout: 5000 }, () => {
const s = new HeadSampler(Number.NaN);
expect(s.shouldSample(mkEvent("x"))).toBe(false);
});
it("clamps out-of-range overrides", { timeout: 5000 }, () => {
const s = new HeadSampler(1, { page: -1, other: 2 });
expect(s.shouldSample(mkEvent("page"))).toBe(false);
expect(s.shouldSample(mkEvent("other"))).toBe(true);
});
});

View File

@@ -0,0 +1,55 @@
import type { AnalyticsEvent, Sampler } from "../types/analytics.types.js";
/**
* Head-based sampler: drawing a uniform random in `[0, 1)` and keeping
* the event when the draw is less than the effective rate. The base
* rate applies to events whose `name` is not in `overrides`; entries in
* `overrides` take precedence.
*
* Edge cases handled explicitly: `rate <= 0` always drops, `rate >= 1`
* always keeps. The `Math.random` call lives behind a constructor-time
* `random` seam so deterministic tests can swap it for a fake.
*/
export class HeadSampler implements Sampler {
private readonly base: number;
private readonly overrides: Readonly<Record<string, number>>;
private readonly random: () => number;
public constructor(
rate: number,
overrides: Readonly<Record<string, number>> = {},
random: () => number = Math.random
) {
this.base = HeadSampler.clamp(rate);
const clamped: Record<string, number> = {};
for (const [k, v] of Object.entries(overrides)) {
clamped[k] = HeadSampler.clamp(v);
}
this.overrides = clamped;
this.random = random;
}
public shouldSample(event: AnalyticsEvent): boolean {
const rate = this.overrides[event.name] ?? this.base;
if (rate >= 1) {
return true;
}
if (rate <= 0) {
return false;
}
return this.random() < rate;
}
private static clamp(rate: number): number {
if (Number.isNaN(rate)) {
return 0;
}
if (rate > 1) {
return 1;
}
if (rate < 0) {
return 0;
}
return rate;
}
}

View File

@@ -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.

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import {
AnalyticsEventSchema,
validateAnalyticsEvent,
} from "./analytics-event-schema.js";
const valid = {
id: "abc",
name: "clicked",
properties: { x: 1 },
timestamp: "2026-05-25T00:00:00.000Z",
sessionId: "s1",
anonymousId: "a1",
};
describe("AnalyticsEventSchema", () => {
it("accepts a well-formed event", { timeout: 5000 }, () => {
const out = validateAnalyticsEvent(valid);
expect(out).toEqual(valid);
});
it("accepts a userId when present", { timeout: 5000 }, () => {
const out = validateAnalyticsEvent({ ...valid, userId: "u1" });
expect(out.userId).toBe("u1");
});
it("rejects events missing required fields", { timeout: 5000 }, () => {
try {
validateAnalyticsEvent({ name: "x" });
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AnalyticsBrowserError);
expect((err as AnalyticsBrowserError).code).toBe(
ANALYTICS_BROWSER_CODES.INVALID_EVENT
);
expect(
Array.isArray((err as AnalyticsBrowserError).context?.issues)
).toBe(true);
}
});
it("exposes the raw Zod schema", { timeout: 5000 }, () => {
const parsed = AnalyticsEventSchema.safeParse(valid);
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,42 @@
import z from "zod";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type { AnalyticsEvent } from "../types/analytics.types.js";
/**
* Zod schema for `AnalyticsEvent`. Mirrors the structural shape used by
* the tracker and every transport. Consumers MAY use this schema to
* validate untrusted input (e.g. events arriving through a postMessage
* bridge from an iframe) before handing them to the tracker.
*/
export const AnalyticsEventSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
properties: z.record(z.string(), z.unknown()),
timestamp: z.string().min(1),
sessionId: z.string().min(1),
anonymousId: z.string().min(1),
userId: z.string().min(1).optional(),
});
/**
* Parse `input` through `AnalyticsEventSchema`. Throws
* `AnalyticsBrowserError(INVALID_EVENT)` on failure, exposing the
* Zod issue list on `error.context.issues`.
*/
export function validateAnalyticsEvent(input: unknown): AnalyticsEvent {
const parsed = AnalyticsEventSchema.safeParse(input);
if (!parsed.success) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.INVALID_EVENT,
"AnalyticsEvent failed schema validation",
{
cause: parsed.error,
context: { issues: parsed.error.issues as unknown[] },
}
);
}
return parsed.data as AnalyticsEvent;
}

View File

@@ -0,0 +1,4 @@
export {
AnalyticsEventSchema,
validateAnalyticsEvent,
} from "./analytics-event-schema.js";

View File

@@ -0,0 +1 @@
export { SessionManager } from "./session.js";

View File

@@ -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.

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionManager } from "./session.js";
function makeClock(start = 1000): {
clock: () => number;
advance: (ms: number) => void;
} {
let now = start;
return {
clock: () => now,
advance: (ms) => {
now += ms;
},
};
}
describe("SessionManager", () => {
it("emits a 32-hex session id by default", { timeout: 5000 }, () => {
const s = new SessionManager();
expect(s.getSessionId()).toMatch(/^[0-9a-f]{32}$/);
});
it("renew generates a fresh session id and zeroes page views", {
timeout: 5000,
}, () => {
const s = new SessionManager();
const before = s.getSessionId();
s.recordPageView();
s.recordPageView();
const after = s.renew();
expect(after).not.toBe(before);
expect(s.getPageViews()).toBe(0);
});
it("inactivity beyond timeoutMs deactivates the session", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const s = new SessionManager({ timeoutMs: 100, clock });
advance(150);
expect(s.isActive()).toBe(false);
});
it("touch resets the inactivity clock", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const s = new SessionManager({ timeoutMs: 100, clock });
advance(50);
s.touch();
advance(60);
expect(s.isActive()).toBe(true);
});
it("end() halts the session", { timeout: 5000 }, () => {
const { clock } = makeClock();
const s = new SessionManager({ timeoutMs: 100, clock });
s.end();
expect(s.isActive()).toBe(false);
});
it("recordPageView is a no-op after end()", { timeout: 5000 }, () => {
const s = new SessionManager();
s.end();
s.recordPageView();
expect(s.getPageViews()).toBe(0);
});
it("touch is a no-op after end()", { timeout: 5000 }, () => {
const s = new SessionManager();
s.end();
s.touch();
expect(s.isActive()).toBe(false);
});
it("duration reflects clock progression", { timeout: 5000 }, () => {
const { clock, advance } = makeClock(0);
const s = new SessionManager({ clock });
advance(500);
expect(s.getDuration()).toBe(500);
});
it("duration after end clamps to last activity", { timeout: 5000 }, () => {
const { clock, advance } = makeClock(0);
const s = new SessionManager({ clock });
advance(300);
s.touch();
advance(200);
s.end();
// After end, getDuration uses lastActivityAt (300) - startedAt (0).
expect(s.getDuration()).toBe(300);
});
it("uses document.referrer when none supplied", { timeout: 5000 }, () => {
// happy-dom exposes document.referrer as "" by default.
const s = new SessionManager();
expect(s.getReferrer()).toBe(
typeof document !== "undefined" ? document.referrer : ""
);
});
it("honours explicit referrer override", { timeout: 5000 }, () => {
const s = new SessionManager({ referrer: "https://x.example" });
expect(s.getReferrer()).toBe("https://x.example");
});
it("getInfo returns a snapshot", { timeout: 5000 }, () => {
const { clock, advance } = makeClock(0);
const s = new SessionManager({ clock });
s.recordPageView();
advance(50);
const info = s.getInfo();
expect(info.pageViews).toBe(1);
expect(info.startedAt).toBe(new Date(0).toISOString());
expect(info.isActive).toBe(true);
expect(info.duration).toBeGreaterThanOrEqual(50);
});
it("falls back to empty referrer when document is undefined", {
timeout: 5000,
}, () => {
const realDoc = globalThis.document;
Object.defineProperty(globalThis, "document", {
value: undefined,
configurable: true,
});
try {
const s = new SessionManager();
expect(s.getReferrer()).toBe("");
} finally {
Object.defineProperty(globalThis, "document", {
value: realDoc,
configurable: true,
});
}
});
it("honours injected idGenerator", { timeout: 5000 }, () => {
let n = 0;
const s = new SessionManager({
idGenerator: () => `sid-${(++n).toString()}`,
});
expect(s.getSessionId()).toBe("sid-1");
const renewed = s.renew();
expect(renewed).toBe("sid-2");
});
});

View File

@@ -0,0 +1,108 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import { DEFAULT_SESSION_TIMEOUT_MS } from "../constants/index.js";
import { generateId } from "../internal/id/id.js";
import type { SessionInfo, SessionOptions } from "../types/analytics.types.js";
/**
* Tracks the lifecycle of a single browser session: id, start time,
* page-view count, referrer, inactivity timeout, and a manual `renew`
* path for SPAs that want to reset the session on logout / login.
*
* Every time-of-now read goes through the injected `clock` so tests can
* drive the inactivity timeout deterministically without sleeping.
*/
export class SessionManager {
private id: string;
private startedAt: number;
private pageViews: number;
private active: boolean;
private lastActivityAt: number;
private readonly referrer: string;
private readonly timeoutMs: number;
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
public constructor(options: SessionOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
this.idGenerator = options.idGenerator ?? generateId;
this.id = this.idGenerator();
const now = this.clock();
this.startedAt = now;
this.lastActivityAt = now;
this.pageViews = 0;
this.active = true;
this.referrer =
options.referrer ??
(typeof document !== "undefined" ? document.referrer : "");
this.timeoutMs = options.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
}
public getSessionId(): string {
return this.id;
}
public getReferrer(): string {
return this.referrer;
}
public getPageViews(): number {
return this.pageViews;
}
public recordPageView(): void {
if (!this.isActive()) {
return;
}
this.pageViews += 1;
this.lastActivityAt = this.clock();
}
public touch(): void {
if (!this.isActive()) {
return;
}
this.lastActivityAt = this.clock();
}
public getDuration(): number {
const end = this.active ? this.clock() : this.lastActivityAt;
return end - this.startedAt;
}
public isActive(): boolean {
if (!this.active) {
return false;
}
const elapsed = this.clock() - this.lastActivityAt;
if (elapsed > this.timeoutMs) {
this.active = false;
return false;
}
return true;
}
public end(): void {
this.active = false;
}
public renew(): string {
this.id = this.idGenerator();
const now = this.clock();
this.startedAt = now;
this.lastActivityAt = now;
this.pageViews = 0;
this.active = true;
return this.id;
}
public getInfo(): SessionInfo {
return {
id: this.id,
startedAt: new Date(this.startedAt).toISOString(),
pageViews: this.pageViews,
duration: this.getDuration(),
referrer: this.referrer,
isActive: this.isActive(),
};
}
}

View File

@@ -0,0 +1,5 @@
export {
MemoryStorageBackend,
WebStorageBackend,
type WebStorageBackendOptions,
} from "./storage.js";

View File

@@ -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.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { MemoryStorageBackend, WebStorageBackend } from "./storage.js";
const events: AnalyticsEvent[] = [
{
id: "1",
name: "a",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "x",
},
{
id: "2",
name: "b",
properties: { k: "v" },
timestamp: "t",
sessionId: "s",
anonymousId: "x",
},
];
class MockStorage implements Storage {
private map = new Map<string, string>();
get length(): number {
return this.map.size;
}
clear(): void {
this.map.clear();
}
getItem(key: string): string | null {
return this.map.get(key) ?? null;
}
key(_index: number): string | null {
return null;
}
removeItem(key: string): void {
this.map.delete(key);
}
setItem(key: string, value: string): void {
this.map.set(key, value);
}
}
describe("MemoryStorageBackend", () => {
it("MemoryStorageBackend round-trips events", {
timeout: 5000,
}, async () => {
const b = new MemoryStorageBackend();
await b.enqueue(events);
expect(await b.getDepth()).toBe(2);
const drained = await b.dequeueAll();
expect(drained).toEqual(events);
expect(await b.getDepth()).toBe(0);
});
it("clear empties the backend", { timeout: 5000 }, async () => {
const b = new MemoryStorageBackend();
await b.enqueue(events);
await b.clear();
expect(await b.getDepth()).toBe(0);
});
});
describe("WebStorageBackend", () => {
it("WebStorageBackend persists across instances", {
timeout: 5000,
}, async () => {
const storage = new MockStorage();
const a = new WebStorageBackend({ storage });
await a.enqueue(events);
const b = new WebStorageBackend({ storage });
expect(await b.dequeueAll()).toEqual(events);
});
it("WebStorageBackend rejects events exceeding maxBytes", {
timeout: 5000,
}, async () => {
const storage = new MockStorage();
const backend = new WebStorageBackend({ storage, maxBytes: 16 });
try {
await backend.enqueue(events);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AnalyticsBrowserError);
expect((err as AnalyticsBrowserError).code).toBe(
ANALYTICS_BROWSER_CODES.QUEUE_FULL
);
}
});
it("getDepth on a fresh storage returns 0", { timeout: 5000 }, async () => {
const storage = new MockStorage();
const backend = new WebStorageBackend({ storage });
expect(await backend.getDepth()).toBe(0);
});
it("clear removes the queue", { timeout: 5000 }, async () => {
const storage = new MockStorage();
const backend = new WebStorageBackend({ storage });
await backend.enqueue(events);
await backend.clear();
expect(await backend.getDepth()).toBe(0);
});
it("throws STORAGE_UNAVAILABLE when constructed without storage", {
timeout: 5000,
}, () => {
try {
// Construct via an object cast so the type assertion is dynamic
// rather than relying on a banned suppression comment.
const opts = { storage: null } as unknown as { storage: Storage };
new WebStorageBackend(opts);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AnalyticsBrowserError);
expect((err as AnalyticsBrowserError).code).toBe(
ANALYTICS_BROWSER_CODES.STORAGE_UNAVAILABLE
);
}
});
it("ignores non-array payload in storage", { timeout: 5000 }, async () => {
const storage = new MockStorage();
storage.setItem("@suaveplan/analytics-browser/queue", '{"oops":true}');
const backend = new WebStorageBackend({ storage });
expect(await backend.getDepth()).toBe(0);
});
it("honours custom key", { timeout: 5000 }, async () => {
const storage = new MockStorage();
const backend = new WebStorageBackend({ storage, key: "custom" });
await backend.enqueue(events);
expect(storage.getItem("custom")).not.toBeNull();
});
});

View File

@@ -0,0 +1,113 @@
import {
DEFAULT_WEB_STORAGE_KEY,
DEFAULT_WEB_STORAGE_MAX_BYTES,
} from "../constants/index.js";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type {
AnalyticsEvent,
StorageBackend,
} from "../types/analytics.types.js";
/**
* In-memory `StorageBackend` — events live in a single array and are
* lost when the tab unloads. Default backend when none is supplied to
* the tracker; suitable for SSR and tests.
*/
export class MemoryStorageBackend implements StorageBackend {
private events: AnalyticsEvent[] = [];
public async enqueue(events: readonly AnalyticsEvent[]): Promise<void> {
this.events.push(...events);
}
public async dequeueAll(): Promise<AnalyticsEvent[]> {
const drained = this.events;
this.events = [];
return drained;
}
public async clear(): Promise<void> {
this.events = [];
}
public async getDepth(): Promise<number> {
return this.events.length;
}
}
/**
* Options for `WebStorageBackend`.
*/
export interface WebStorageBackendOptions {
readonly storage: Storage;
readonly key?: string;
readonly maxBytes?: number;
}
/**
* DOM-`Storage`-backed `StorageBackend` — events are JSON-encoded under
* a single key. Works against any `Storage` implementation
* (`localStorage`, `sessionStorage`, an injected polyfill). The
* `maxBytes` watermark causes overflow attempts to throw
* `AnalyticsBrowserError(QUEUE_FULL)`.
*/
export class WebStorageBackend implements StorageBackend {
private readonly storage: Storage;
private readonly key: string;
private readonly maxBytes: number;
public constructor(options: WebStorageBackendOptions) {
if (options.storage === undefined || options.storage === null) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.STORAGE_UNAVAILABLE,
"WebStorageBackend requires a Storage instance"
);
}
this.storage = options.storage;
this.key = options.key ?? DEFAULT_WEB_STORAGE_KEY;
this.maxBytes = options.maxBytes ?? DEFAULT_WEB_STORAGE_MAX_BYTES;
}
public async enqueue(events: readonly AnalyticsEvent[]): Promise<void> {
const current = this.read();
const next = current.concat(...events);
const encoded = JSON.stringify(next);
if (encoded.length > this.maxBytes) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.QUEUE_FULL,
"WebStorageBackend exceeded maxBytes",
{ context: { bytes: encoded.length, maxBytes: this.maxBytes } }
);
}
this.storage.setItem(this.key, encoded);
}
public async dequeueAll(): Promise<AnalyticsEvent[]> {
const current = this.read();
this.storage.removeItem(this.key);
return current;
}
public async clear(): Promise<void> {
this.storage.removeItem(this.key);
}
public async getDepth(): Promise<number> {
return this.read().length;
}
private read(): AnalyticsEvent[] {
const raw = this.storage.getItem(this.key);
if (raw === null) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed as AnalyticsEvent[];
}
}

View File

@@ -0,0 +1,10 @@
export {
batchSizeHistogram,
eventsDroppedCounter,
eventsFlushedCounter,
eventsTrackedCounter,
flushLatencyHistogram,
recordDropped,
recordEventTracked,
recordFlush,
} from "./telemetry.js";

View File

@@ -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`.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { recordDropped, recordEventTracked, recordFlush } from "./telemetry.js";
interface Point {
descriptor: { name: string };
}
function collectNames(raw: unknown): string[] {
const out: string[] = [];
const top = raw as Array<{
scopeMetrics: Array<{ metrics: Array<Point> }>;
}>;
for (const t of top) {
for (const sm of t.scopeMetrics) {
for (const m of sm.metrics) {
out.push(m.descriptor.name);
}
}
}
return out;
}
describe("telemetry", () => {
it("records tracked events", { timeout: 5000 }, async () => {
recordEventTracked("clicked");
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain(
"analytics_browser.events_tracked_total"
);
});
it("records flush status", { timeout: 5000 }, async () => {
recordFlush("success", 3, 5);
recordFlush("failure", 0, 7);
const flushed = await flushTestMetrics();
const names = collectNames(flushed);
expect(names).toContain("analytics_browser.events_flushed_total");
expect(names).toContain("analytics_browser.batch_size");
expect(names).toContain("analytics_browser.flush_latency_ms");
});
it("records drop reason", { timeout: 5000 }, async () => {
recordDropped("consent");
recordDropped("sampling");
const flushed = await flushTestMetrics();
expect(collectNames(flushed)).toContain(
"analytics_browser.events_dropped_total"
);
});
});

View File

@@ -0,0 +1,77 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import type { DropReason } from "../types/analytics.types.js";
const METER_NAME = "@suaveplan/analytics-browser";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider`
* on first access. When no provider is registered the OTel API returns
* a no-op meter, so production code that does not opt into telemetry
* pays a single property-read cost per recorded event.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for accepted (post-consent + post-sampling) tracked events, tagged by `name`. */
export const eventsTrackedCounter: Counter = meter.createCounter(
"analytics_browser.events_tracked_total",
{
description:
"Total accepted events handed to the queue, tagged with the event name",
}
);
/** Counter for flush attempts, tagged by `status` (`success` / `failure`). */
export const eventsFlushedCounter: Counter = meter.createCounter(
"analytics_browser.events_flushed_total",
{
description:
"Total flush attempts against the transport, tagged with `status`",
}
);
/** Counter for dropped events, tagged by `reason` (`consent` / `sampling` / `queue_full` / `shutdown` / `invalid`). */
export const eventsDroppedCounter: Counter = meter.createCounter(
"analytics_browser.events_dropped_total",
{
description:
"Total events dropped before reaching the queue, tagged with `reason`",
}
);
/** Histogram of batch sizes observed during flush. */
export const batchSizeHistogram: Histogram = meter.createHistogram(
"analytics_browser.batch_size",
{
description: "Number of events delivered per flush",
}
);
/** Histogram of flush latencies in milliseconds. */
export const flushLatencyHistogram: Histogram = meter.createHistogram(
"analytics_browser.flush_latency_ms",
{
description: "Wall-clock time spent inside a single `transport.send`",
unit: "ms",
}
);
/** Record a single accepted event. */
export function recordEventTracked(name: string): void {
eventsTrackedCounter.add(1, { name });
}
/** Record a flush attempt. */
export function recordFlush(
status: "success" | "failure",
eventCount: number,
latencyMs: number
): void {
eventsFlushedCounter.add(1, { status });
batchSizeHistogram.record(eventCount);
flushLatencyHistogram.record(latencyMs);
}
/** Record a dropped event. */
export function recordDropped(reason: DropReason): void {
eventsDroppedCounter.add(1, { reason });
}

View File

@@ -0,0 +1 @@
export { AnalyticsTracker } from "./tracker.js";

View File

@@ -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.

View File

@@ -0,0 +1,609 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { DoNotTrackPolicy } from "../consent/consent.js";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import { HeadSampler } from "../sampling/sampling.js";
import { MemoryStorageBackend } from "../storage/storage.js";
import type {
AnalyticsEvent,
StorageBackend,
Transport,
} from "../types/analytics.types.js";
import { AnalyticsTracker } from "./tracker.js";
class CapturingTransport implements Transport {
public batches: AnalyticsEvent[][] = [];
public response: boolean | "throw" = true;
public sendCalls = 0;
public shutdownCalls = 0;
public async send(events: readonly AnalyticsEvent[]): Promise<boolean> {
this.sendCalls += 1;
this.batches.push([...events]);
if (this.response === "throw") {
throw new Error("boom");
}
return this.response;
}
public async shutdown(): Promise<void> {
this.shutdownCalls += 1;
}
}
const noopTimers: TimersLike = {
setTimer: () => 0,
clearTimer: () => {},
};
describe("AnalyticsTracker", () => {
it("track enqueues a well-formed event", { timeout: 5000 }, () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 10,
timers: noopTimers,
});
let captured: AnalyticsEvent | undefined;
tracker.events.on("track", ({ event }) => {
captured = event;
});
tracker.track("clicked", { x: 1 });
expect(tracker.getQueueSize()).toBe(1);
expect(captured?.name).toBe("clicked");
expect(captured?.properties).toEqual({ x: 1 });
expect(captured?.sessionId).toBe(tracker.getSessionId());
expect(captured?.anonymousId).toBe(tracker.getAnonymousId());
});
it("consent block drops the event", { timeout: 5000 }, () => {
Object.defineProperty(navigator, "doNotTrack", {
value: "1",
configurable: true,
});
try {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
consentPolicy: new DoNotTrackPolicy(),
timers: noopTimers,
});
let dropReason: string | undefined;
tracker.events.on("drop", ({ reason }) => {
dropReason = reason;
});
tracker.track("clicked");
expect(tracker.getQueueSize()).toBe(0);
expect(dropReason).toBe("consent");
} finally {
Object.defineProperty(navigator, "doNotTrack", {
value: "0",
configurable: true,
});
}
});
it("sampling drop emits drop with reason sampling", {
timeout: 5000,
}, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
sampler: new HeadSampler(0),
timers: noopTimers,
});
let reason: string | undefined;
tracker.events.on("drop", (payload) => {
reason = payload.reason;
});
tracker.track("clicked");
expect(tracker.getQueueSize()).toBe(0);
expect(reason).toBe("sampling");
});
it("PII scrubber redacts before enqueue", { timeout: 5000 }, () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
scrubRules: [{ field: "email", strategy: "redact" }],
batchSize: 1,
timers: noopTimers,
});
tracker.track("clicked", { email: "x@y" });
return tracker.flush().then(() => {
expect(transport.batches[0]?.[0]?.properties.email).toBe(
"[REDACTED]"
);
});
});
it("batch-size threshold triggers flush", { timeout: 5000 }, async () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 2,
timers: noopTimers,
});
tracker.track("a");
tracker.track("b");
// Wait for the microtask scheduled by the in-flight flush.
await Promise.resolve();
await Promise.resolve();
expect(transport.sendCalls).toBe(1);
expect(transport.batches[0]?.length).toBe(2);
});
it("shutdown ignores subsequent track calls", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
timers: noopTimers,
});
let shutdownCount = 0;
tracker.events.on("shutdown", () => {
shutdownCount += 1;
});
await tracker.shutdown();
tracker.track("ignored");
expect(tracker.getQueueSize()).toBe(0);
expect(shutdownCount).toBe(1);
});
it("storage backend receives every accepted event", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
const storage = new MemoryStorageBackend();
const tracker = new AnalyticsTracker(transport, {
storage,
batchSize: 10,
timers: noopTimers,
});
tracker.track("a");
tracker.track("b");
// Persistence is async — wait microtasks.
await Promise.resolve();
await Promise.resolve();
expect(await storage.getDepth()).toBe(2);
});
it("successful flush clears the storage backend", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
const storage = new MemoryStorageBackend();
const tracker = new AnalyticsTracker(transport, {
storage,
batchSize: 1,
timers: noopTimers,
});
tracker.track("a");
await Promise.resolve();
await tracker.flush();
expect(await storage.getDepth()).toBe(0);
});
it("restore replays persisted events into the in-memory queue", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorageBackend();
await storage.enqueue([
{
id: "1",
name: "a",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "x",
},
{
id: "2",
name: "b",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "x",
},
]);
const tracker = new AnalyticsTracker(new CapturingTransport(), {
storage,
batchSize: 100,
timers: noopTimers,
});
await tracker.restore();
expect(tracker.getQueueSize()).toBe(2);
expect(await storage.getDepth()).toBe(2);
});
it("identify pins userId on subsequent events", { timeout: 5000 }, () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 10,
timers: noopTimers,
});
let observed: AnalyticsEvent | undefined;
tracker.events.on("track", ({ event }) => {
observed = event;
});
tracker.identify("u1", { plan: "pro" });
tracker.track("event");
expect(observed?.userId).toBe("u1");
expect(observed?.properties.plan).toBe("pro");
expect(tracker.getUserId()).toBe("u1");
});
it("setProperties merges into every event", { timeout: 5000 }, () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 10,
timers: noopTimers,
});
let captured: AnalyticsEvent | undefined;
tracker.events.on("track", ({ event }) => {
captured = event;
});
tracker.setProperties({ app: "shell" });
tracker.track("a", { x: 1 });
expect(captured?.properties).toEqual({ app: "shell", x: 1 });
});
it("page() emits both track and pageview", { timeout: 5000 }, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let pageEvent: { url: string } | undefined;
let trackEvent: AnalyticsEvent | undefined;
tracker.events.on("pageview", ({ url }) => {
pageEvent = { url };
});
tracker.events.on("track", ({ event }) => {
trackEvent = event;
});
tracker.page("/home", { sec: "top" });
expect(pageEvent?.url).toBe("/home");
expect(trackEvent?.properties.url).toBe("/home");
expect(trackEvent?.properties.sec).toBe("top");
});
it("page() falls back to location.href when no url", {
timeout: 5000,
}, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let pageEvent: { url: string } | undefined;
tracker.events.on("pageview", ({ url }) => {
pageEvent = { url };
});
tracker.page();
expect(typeof pageEvent?.url).toBe("string");
});
it("flush with empty queue is a no-op success", {
timeout: 5000,
}, async () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
expect(await tracker.flush()).toBe(true);
});
it("concurrent flush returns the in-flight promise", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 100,
timers: noopTimers,
});
tracker.track("a");
await Promise.resolve();
const a = tracker.flush();
const b = tracker.flush();
expect(await a).toBe(true);
expect(await b).toBe(true);
expect(transport.sendCalls).toBe(1);
});
it("flush failure restores queue and emits flush+success=false", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
transport.response = false;
const tracker = new AnalyticsTracker(transport, {
batchSize: 100,
timers: noopTimers,
});
tracker.track("a");
await Promise.resolve();
let success: boolean | undefined;
tracker.events.on("flush", (payload) => {
success = payload.success;
});
expect(await tracker.flush()).toBe(false);
expect(tracker.getQueueSize()).toBe(1);
expect(success).toBe(false);
});
it("flush thrown error emits error+flush failure", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
transport.response = "throw";
const tracker = new AnalyticsTracker(transport, {
batchSize: 100,
timers: noopTimers,
});
tracker.track("a");
await Promise.resolve();
let errCtx: string | undefined;
tracker.events.on("error", ({ context }) => {
errCtx = context;
});
expect(await tracker.flush()).toBe(false);
expect(errCtx).toBe("flush");
expect(tracker.getQueueSize()).toBe(1);
});
it("flush thrown non-Error wraps in Error", { timeout: 5000 }, async () => {
const transport: Transport = {
send: async () => {
throw "string-failure";
},
shutdown: async () => {},
};
const tracker = new AnalyticsTracker(transport, {
batchSize: 100,
timers: noopTimers,
});
tracker.track("a");
await Promise.resolve();
let errMessage: string | undefined;
tracker.events.on("error", ({ error }) => {
errMessage = error.message;
});
expect(await tracker.flush()).toBe(false);
expect(errMessage).toBe("string-failure");
});
it("setSessionId updates the session pin", { timeout: 5000 }, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let captured: AnalyticsEvent | undefined;
tracker.events.on("track", ({ event }) => {
captured = event;
});
tracker.setSessionId("custom-session");
tracker.track("a");
expect(captured?.sessionId).toBe("custom-session");
expect(tracker.getSessionId()).toBe("custom-session");
});
it("queue_full from storage drops + records + propagates", {
timeout: 5000,
}, async () => {
const storage: StorageBackend = {
enqueue: async () => {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.QUEUE_FULL,
"full"
);
},
dequeueAll: async () => [],
clear: async () => {},
getDepth: async () => 0,
};
const tracker = new AnalyticsTracker(new CapturingTransport(), {
storage,
timers: noopTimers,
});
let dropReason: string | undefined;
let errorContext: string | undefined;
tracker.events.on("drop", ({ reason }) => {
dropReason = reason;
});
tracker.events.on("error", ({ context }) => {
errorContext = context;
});
tracker.track("a");
await Promise.resolve();
await Promise.resolve();
expect(dropReason).toBe("queue_full");
expect(errorContext).toBe("track.persist");
});
it("non-QueueFull storage error emits error event", {
timeout: 5000,
}, async () => {
const storage: StorageBackend = {
enqueue: async () => {
throw new Error("disk gone");
},
dequeueAll: async () => [],
clear: async () => {},
getDepth: async () => 0,
};
const tracker = new AnalyticsTracker(new CapturingTransport(), {
storage,
timers: noopTimers,
});
let errMsg: string | undefined;
tracker.events.on("error", ({ error }) => {
errMsg = error.message;
});
tracker.track("a");
await Promise.resolve();
await Promise.resolve();
expect(errMsg).toBe("disk gone");
});
it("auto-flush timer fires", { timeout: 5000 }, async () => {
let fireCallback: (() => void) | undefined;
const transport = new CapturingTransport();
const timers: TimersLike = {
setTimer: (cb) => {
fireCallback = cb;
return 1;
},
clearTimer: () => {},
};
const tracker = new AnalyticsTracker(transport, {
batchSize: 100,
flushInterval: 1000,
timers,
});
tracker.track("a");
await Promise.resolve();
fireCallback?.();
await Promise.resolve();
await Promise.resolve();
expect(transport.sendCalls).toBe(1);
});
it("flushInterval of 0 disables auto-flush", { timeout: 5000 }, () => {
let armed = 0;
const timers: TimersLike = {
setTimer: () => {
armed += 1;
return 1;
},
clearTimer: () => {},
};
new AnalyticsTracker(new CapturingTransport(), {
flushInterval: 0,
timers,
});
expect(armed).toBe(0);
});
it("repeated shutdown is idempotent", { timeout: 5000 }, async () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
timers: noopTimers,
});
await tracker.shutdown();
await tracker.shutdown();
expect(transport.shutdownCalls).toBe(1);
});
it("uses default timers when none injected", {
timeout: 5000,
}, async () => {
const transport = new CapturingTransport();
const tracker = new AnalyticsTracker(transport, {
batchSize: 1000,
flushInterval: 50,
});
tracker.track("a");
await new Promise((resolve) => setTimeout(resolve, 80));
// The default timer should have fired by now.
expect(transport.sendCalls).toBeGreaterThanOrEqual(1);
await tracker.shutdown();
});
it("constructs without options", { timeout: 5000 }, async () => {
const tracker = new AnalyticsTracker(new CapturingTransport());
expect(tracker.getAnonymousId().length).toBeGreaterThan(0);
await tracker.shutdown();
});
it("restore from an empty storage is a no-op", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorageBackend();
const tracker = new AnalyticsTracker(new CapturingTransport(), {
storage,
timers: noopTimers,
});
await tracker.restore();
expect(tracker.getQueueSize()).toBe(0);
});
it("identify without traits emits payload without traits", {
timeout: 5000,
}, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let captured:
| { userId: string; traits?: Record<string, unknown> }
| undefined;
tracker.events.on("identify", (payload) => {
captured = payload;
});
tracker.identify("u1");
expect(captured?.userId).toBe("u1");
expect("traits" in (captured ?? {})).toBe(false);
});
it("page() with no url and no location falls back to empty string", {
timeout: 5000,
}, () => {
const realLoc = globalThis.location;
Object.defineProperty(globalThis, "location", {
value: undefined,
configurable: true,
});
try {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let pageUrl: string | undefined;
tracker.events.on("pageview", ({ url }) => {
pageUrl = url;
});
tracker.page();
expect(pageUrl).toBe("");
} finally {
Object.defineProperty(globalThis, "location", {
value: realLoc,
configurable: true,
});
}
});
it("non-Error storage rejection wraps into Error", {
timeout: 5000,
}, async () => {
const storage: StorageBackend = {
enqueue: async () => {
throw "raw string";
},
dequeueAll: async () => [],
clear: async () => {},
getDepth: async () => 0,
};
const tracker = new AnalyticsTracker(new CapturingTransport(), {
storage,
timers: noopTimers,
});
let captured: Error | undefined;
tracker.events.on("error", ({ error }) => {
captured = error;
});
tracker.track("a");
await Promise.resolve();
await Promise.resolve();
expect(captured?.message).toBe("raw string");
});
it("shutdown is a no-op when flushInterval=0", {
timeout: 5000,
}, async () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
flushInterval: 0,
timers: noopTimers,
});
await tracker.shutdown();
});
it("uses default clock when none injected", { timeout: 5000 }, () => {
const tracker = new AnalyticsTracker(new CapturingTransport(), {
timers: noopTimers,
});
let stamp = "";
tracker.events.on("track", ({ event }) => {
stamp = event.timestamp;
});
tracker.track("a");
expect(stamp.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(stamp))).toBe(false);
});
});

View File

@@ -0,0 +1,294 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, IdGenerator, TimersLike } from "@suaveplan/types";
import { AllowAllPolicy } from "../consent/consent.js";
import {
DEFAULT_BATCH_SIZE,
DEFAULT_FLUSH_INTERVAL_MS,
} from "../constants/index.js";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import { generateId } from "../internal/id/id.js";
import { scrubPII } from "../pii/pii.js";
import { HeadSampler } from "../sampling/sampling.js";
import { MemoryStorageBackend } from "../storage/storage.js";
import {
recordDropped,
recordEventTracked,
recordFlush,
} from "../telemetry/telemetry.js";
import type {
AnalyticsEvent,
AnalyticsEventMap,
ConsentPolicy,
DropReason,
Sampler,
ScrubRule,
StorageBackend,
TrackerOptions,
Transport,
} from "../types/analytics.types.js";
const defaultTimers: TimersLike = {
setTimer: (callback, delayMs) =>
setInterval(callback, delayMs) as unknown as number,
clearTimer: (handle) => {
clearInterval(handle as unknown as ReturnType<typeof setInterval>);
},
};
/**
* Browser-tier analytics tracker. Buffers events through a consent →
* sampling → PII scrub pipeline before persisting to an in-memory FIFO
* and a `StorageBackend`, then flushes batches through the injected
* `Transport`. Lifecycle events are emitted on a typed
* `EventBus<AnalyticsEventMap>`.
*/
export class AnalyticsTracker {
public readonly events: EventBus<AnalyticsEventMap>;
private readonly transport: Transport;
private readonly storage: StorageBackend;
private readonly consent: ConsentPolicy;
private readonly sampler: Sampler;
private readonly scrubRules: readonly ScrubRule[];
private readonly batchSize: number;
private readonly flushInterval: number;
private readonly clock: Clock;
private readonly timers: TimersLike;
private readonly idGenerator: IdGenerator;
private readonly anonymousId: string;
private queue: AnalyticsEvent[] = [];
private userId: string | undefined;
private globalProperties: Record<string, unknown> = {};
private sessionId: string;
private isShutdown = false;
private flushHandle: number | null = null;
private flushInFlight: Promise<boolean> | null = null;
public constructor(transport: Transport, options: TrackerOptions = {}) {
this.transport = transport;
this.storage = options.storage ?? new MemoryStorageBackend();
this.consent = options.consentPolicy ?? new AllowAllPolicy();
this.sampler = options.sampler ?? new HeadSampler(1);
this.scrubRules = options.scrubRules ?? [];
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS;
this.clock = options.clock ?? (() => Date.now());
this.timers = options.timers ?? defaultTimers;
this.idGenerator = options.idGenerator ?? generateId;
this.events = new EventBus<AnalyticsEventMap>();
this.anonymousId = this.idGenerator();
this.sessionId = this.idGenerator();
this.startAutoFlush();
}
/** Restore previously persisted events from the storage backend. */
public async restore(): Promise<void> {
const restored = await this.storage.dequeueAll();
this.queue.push(...restored);
// Re-enqueue into storage so a subsequent failed flush still has
// durable backing.
if (restored.length > 0) {
await this.storage.enqueue(restored);
}
}
public track(name: string, properties: Record<string, unknown> = {}): void {
if (this.isShutdown) {
this.dropAndEmit("shutdown");
return;
}
const merged: Record<string, unknown> = {
...this.globalProperties,
...properties,
};
const baseEvent: Omit<AnalyticsEvent, "userId"> = {
id: this.idGenerator(),
name,
properties: merged,
timestamp: new Date(this.clock()).toISOString(),
sessionId: this.sessionId,
anonymousId: this.anonymousId,
};
const event: AnalyticsEvent =
this.userId === undefined
? baseEvent
: { ...baseEvent, userId: this.userId };
if (!this.consent.isAllowed(event)) {
this.dropAndEmit("consent", event);
return;
}
if (!this.sampler.shouldSample(event)) {
this.dropAndEmit("sampling", event);
return;
}
const scrubbed =
this.scrubRules.length > 0
? scrubPII(event, this.scrubRules)
: event;
this.persist(scrubbed).catch((err: unknown) => {
if (
err instanceof AnalyticsBrowserError &&
err.code === ANALYTICS_BROWSER_CODES.QUEUE_FULL
) {
this.dropAndEmit("queue_full", scrubbed);
}
this.events.emit("error", {
error: err instanceof Error ? err : new Error(String(err)),
context: "track.persist",
});
});
this.queue.push(scrubbed);
this.events.emit("track", { event: scrubbed });
recordEventTracked(scrubbed.name);
if (this.queue.length >= this.batchSize) {
void this.flush();
}
}
public page(url?: string, properties: Record<string, unknown> = {}): void {
const target =
url ?? (typeof location !== "undefined" ? location.href : "");
const merged = { ...properties, url: target };
this.track("page", merged);
this.events.emit("pageview", { url: target, properties: merged });
}
public identify(userId: string, traits?: Record<string, unknown>): void {
this.userId = userId;
if (traits !== undefined) {
this.setProperties(traits);
}
this.events.emit(
"identify",
traits === undefined ? { userId } : { userId, traits }
);
}
public setProperties(properties: Record<string, unknown>): void {
this.globalProperties = { ...this.globalProperties, ...properties };
}
public getAnonymousId(): string {
return this.anonymousId;
}
public getUserId(): string | undefined {
return this.userId;
}
public getSessionId(): string {
return this.sessionId;
}
public setSessionId(sessionId: string): void {
this.sessionId = sessionId;
}
public getQueueSize(): number {
return this.queue.length;
}
public async flush(): Promise<boolean> {
if (this.flushInFlight !== null) {
return this.flushInFlight;
}
if (this.queue.length === 0) {
return true;
}
const drained = this.queue;
this.queue = [];
const start = this.clock();
const promise = (async () => {
try {
const ok = await this.transport.send(drained);
const latency = this.clock() - start;
if (ok) {
await this.storage.clear();
this.events.emit("flush", {
eventCount: drained.length,
success: true,
latencyMs: latency,
});
recordFlush("success", drained.length, latency);
return true;
}
this.queue.unshift(...drained);
this.events.emit("flush", {
eventCount: drained.length,
success: false,
latencyMs: latency,
});
recordFlush("failure", drained.length, latency);
return false;
} catch (err) {
this.queue.unshift(...drained);
const error =
err instanceof Error ? err : new Error(String(err));
this.events.emit("error", { error, context: "flush" });
const latency = this.clock() - start;
this.events.emit("flush", {
eventCount: drained.length,
success: false,
latencyMs: latency,
});
recordFlush("failure", drained.length, latency);
return false;
} finally {
this.flushInFlight = null;
}
})();
this.flushInFlight = promise;
return promise;
}
public async shutdown(): Promise<void> {
if (this.isShutdown) {
return;
}
this.isShutdown = true;
this.stopAutoFlush();
await this.flush();
await this.transport.shutdown();
this.events.emit("shutdown", {});
}
private async persist(event: AnalyticsEvent): Promise<void> {
try {
await this.storage.enqueue([event]);
} catch (err) {
if (
err instanceof AnalyticsBrowserError &&
err.code === ANALYTICS_BROWSER_CODES.QUEUE_FULL
) {
this.dropAndEmit("queue_full", event);
throw err;
}
throw err;
}
}
private dropAndEmit(reason: DropReason, event?: AnalyticsEvent): void {
const payload = event === undefined ? { reason } : { reason, event };
this.events.emit("drop", payload);
recordDropped(reason);
}
private startAutoFlush(): void {
if (this.flushInterval > 0) {
this.flushHandle = this.timers.setTimer(() => {
void this.flush();
}, this.flushInterval);
}
}
private stopAutoFlush(): void {
if (this.flushHandle !== null) {
this.timers.clearTimer(this.flushHandle);
this.flushHandle = null;
}
}
}

View File

@@ -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.

View File

@@ -0,0 +1,268 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { BatchTransport } from "./batch.js";
const event: AnalyticsEvent = {
id: "1",
name: "n",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "a",
};
function recordingTimers(): TimersLike & { armed: number; cleared: number } {
let armed = 0;
let cleared = 0;
return {
get armed() {
return armed;
},
get cleared() {
return cleared;
},
setTimer(callback, delayMs) {
armed += 1;
return setTimeout(callback, delayMs) as unknown as number;
},
clearTimer(handle) {
cleared += 1;
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
},
} as TimersLike & { armed: number; cleared: number };
}
function jsonResponse(status: number, body: unknown = { ok: true }): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("BatchTransport", () => {
it("rejects construction without an endpoint", { timeout: 5000 }, () => {
try {
new BatchTransport({ endpoint: "" });
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(AnalyticsBrowserError);
expect((err as AnalyticsBrowserError).code).toBe(
ANALYTICS_BROWSER_CODES.INVALID_OPTIONS
);
}
});
it("returns success when there are no events", {
timeout: 5000,
}, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
fetchFn: async () => {
calls += 1;
return jsonResponse(200);
},
});
expect(await t.send([])).toBe(true);
expect(calls).toBe(0);
});
it("200 response resolves to success", { timeout: 5000 }, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
fetchFn: async () => {
calls += 1;
return jsonResponse(200);
},
});
expect(await t.send([event])).toBe(true);
expect(calls).toBe(1);
});
it("4xx response short-circuits with no retry", {
timeout: 5000,
}, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 3,
retryDelay: 1,
fetchFn: async () => {
calls += 1;
return jsonResponse(400);
},
});
expect(await t.send([event])).toBe(false);
expect(calls).toBe(1);
});
it("5xx retries with exponential backoff", { timeout: 5000 }, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 2,
retryDelay: 1,
fetchFn: async () => {
calls += 1;
return calls <= 2 ? jsonResponse(503) : jsonResponse(200);
},
});
expect(await t.send([event])).toBe(true);
expect(calls).toBe(3);
});
it("network error retries and eventually returns false", {
timeout: 5000,
}, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 1,
retryDelay: 1,
fetchFn: async () => {
calls += 1;
throw new Error("offline");
},
});
expect(await t.send([event])).toBe(false);
expect(calls).toBe(2);
});
it("timeoutMs aborts the fetch and throws TRANSPORT_TIMEOUT", {
timeout: 5000,
}, async () => {
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 0,
timeoutMs: 10,
fetchFn: (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const reason =
(init.signal as AbortSignal).reason ??
new Error("aborted");
reject(reason as Error);
});
}),
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
});
});
it("aborted fetch with non-timeout cause still throws TRANSPORT_TIMEOUT", {
timeout: 5000,
}, async () => {
// Simulate a host-aborted fetch where the rejection reason is not
// our own AnalyticsBrowserError instance — the transport must
// still translate that to TRANSPORT_TIMEOUT because the signal
// is aborted.
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 0,
timeoutMs: 10,
fetchFn: (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("DOMException-like abort"));
});
}),
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
});
});
it("successful send clears the armed timeout", {
timeout: 5000,
}, async () => {
const timers = recordingTimers();
const t = new BatchTransport({
endpoint: "/x",
timers,
fetchFn: async () => jsonResponse(200),
});
await t.send([event]);
expect(timers.armed).toBe(timers.cleared);
});
it("timeout abort clears the armed timeout", {
timeout: 5000,
}, async () => {
const timers = recordingTimers();
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 0,
timeoutMs: 5,
timers,
fetchFn: (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("abort"));
});
}),
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
});
expect(timers.armed).toBe(timers.cleared);
});
it("uses global fetch when no fetchFn injected", {
timeout: 5000,
}, async () => {
let calls = 0;
const realFetch = globalThis.fetch;
Object.defineProperty(globalThis, "fetch", {
value: async (..._args: unknown[]) => {
calls += 1;
return jsonResponse(200);
},
configurable: true,
writable: true,
});
try {
const t = new BatchTransport({ endpoint: "/x", maxRetries: 0 });
expect(await t.send([event])).toBe(true);
expect(calls).toBe(1);
} finally {
Object.defineProperty(globalThis, "fetch", {
value: realFetch,
configurable: true,
writable: true,
});
}
});
it("uses default timers when none injected", {
timeout: 5000,
}, async () => {
const t = new BatchTransport({
endpoint: "/x",
maxRetries: 0,
fetchFn: async () => jsonResponse(200),
});
expect(await t.send([event])).toBe(true);
});
it("shutdown disables further sends", { timeout: 5000 }, async () => {
let calls = 0;
const t = new BatchTransport({
endpoint: "/x",
fetchFn: async () => {
calls += 1;
return jsonResponse(200);
},
});
await t.shutdown();
expect(await t.send([event])).toBe(true);
expect(calls).toBe(0);
expect(t.isActive()).toBe(false);
});
});

View File

@@ -0,0 +1,147 @@
import type { TimersLike } from "@suaveplan/types";
import {
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_DELAY_MS,
DEFAULT_TRANSPORT_TIMEOUT_MS,
} from "../constants/index.js";
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type {
AnalyticsEvent,
BatchTransportOptions,
Transport,
} from "../types/analytics.types.js";
const defaultTimers: TimersLike = {
setTimer: (callback, delayMs) =>
setTimeout(callback, delayMs) as unknown as number,
clearTimer: (handle) => {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
},
};
/**
* HTTP `BatchTransport` — POSTs `{ events }` to the configured endpoint
* with `AbortController`-driven timeout and exponential backoff. `4xx`
* responses short-circuit (no retry); `5xx` / network errors retry up
* to `maxRetries` times with delay `retryDelay * 2 ** attempt`.
*/
export class BatchTransport implements Transport {
private readonly endpoint: string;
private readonly maxRetries: number;
private readonly retryDelay: number;
private readonly timeoutMs: number;
private readonly fetchFn: typeof fetch;
private readonly timers: TimersLike;
private active = true;
public constructor(options: BatchTransportOptions) {
if (!options.endpoint) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.INVALID_OPTIONS,
"BatchTransport requires a non-empty endpoint"
);
}
this.endpoint = options.endpoint;
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
this.retryDelay = options.retryDelay ?? DEFAULT_RETRY_DELAY_MS;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS;
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
this.timers = options.timers ?? defaultTimers;
}
public async send(events: readonly AnalyticsEvent[]): Promise<boolean> {
if (!this.active || events.length === 0) {
return true;
}
const body = JSON.stringify({ events });
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
const outcome = await this.attempt(body);
if (outcome === "success") {
return true;
}
if (outcome === "client-error") {
return false;
}
// outcome === "retry": back off then loop.
if (attempt < this.maxRetries) {
await this.sleep(this.retryDelay * 2 ** attempt);
}
}
return false;
}
public async shutdown(): Promise<void> {
this.active = false;
}
public isActive(): boolean {
return this.active;
}
private async attempt(
body: string
): Promise<"success" | "client-error" | "retry"> {
const controller = new AbortController();
const handle = this.timers.setTimer(() => {
controller.abort(
new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
"BatchTransport timed out",
{
context: {
endpoint: this.endpoint,
timeoutMs: this.timeoutMs,
},
}
)
);
}, this.timeoutMs);
try {
const response = await this.fetchFn(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: controller.signal,
});
if (response.ok) {
return "success";
}
if (response.status >= 400 && response.status < 500) {
return "client-error";
}
return "retry";
} catch (err) {
if (
err instanceof AnalyticsBrowserError &&
err.code === ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT
) {
throw err;
}
if (controller.signal.aborted) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT,
"BatchTransport timed out",
{
cause: err,
context: {
endpoint: this.endpoint,
timeoutMs: this.timeoutMs,
},
}
);
}
return "retry";
} finally {
this.timers.clearTimer(handle);
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
this.timers.setTimer(() => resolve(), ms);
});
}
}

View File

@@ -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.

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ANALYTICS_BROWSER_CODES,
type AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type { AnalyticsEvent } from "../types/analytics.types.js";
import { BeaconTransport } from "./beacon.js";
const event: AnalyticsEvent = {
id: "1",
name: "n",
properties: {},
timestamp: "t",
sessionId: "s",
anonymousId: "a",
};
describe("BeaconTransport", () => {
it("rejects construction without an endpoint", { timeout: 5000 }, () => {
try {
new BeaconTransport({ endpoint: "" });
throw new Error("did not throw");
} catch (err) {
expect((err as AnalyticsBrowserError).code).toBe(
ANALYTICS_BROWSER_CODES.INVALID_OPTIONS
);
}
});
it("returns true on empty events", { timeout: 5000 }, async () => {
let beaconCalls = 0;
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: () => {
beaconCalls += 1;
return true;
},
});
expect(await t.send([])).toBe(true);
expect(beaconCalls).toBe(0);
});
it("sendBeacon true resolves to success", { timeout: 5000 }, async () => {
let observed = "";
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: (_url, body) => {
observed = body as string;
return true;
},
});
expect(await t.send([event])).toBe(true);
expect(observed).toContain('"name":"n"');
});
it("sendBeacon false with fallback uses fetch keepalive", {
timeout: 5000,
}, async () => {
let fetchInit: RequestInit | undefined;
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: () => false,
fallbackToFetch: true,
fetchFn: async (_url, init) => {
fetchInit = init;
return new Response("", { status: 200 });
},
});
expect(await t.send([event])).toBe(true);
expect(fetchInit?.keepalive).toBe(true);
});
it("sendBeacon false without fallback throws BEACON_REJECTED", {
timeout: 5000,
}, async () => {
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: () => false,
fallbackToFetch: false,
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.BEACON_REJECTED,
});
});
it("fallback fetch failure surfaces as false", {
timeout: 5000,
}, async () => {
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: () => false,
fallbackToFetch: true,
fetchFn: async () => new Response("", { status: 500 }),
});
expect(await t.send([event])).toBe(false);
});
it("shutdown disables future sends", { timeout: 5000 }, async () => {
let beaconCalls = 0;
const t = new BeaconTransport({
endpoint: "/x",
sendBeaconFn: () => {
beaconCalls += 1;
return true;
},
});
await t.shutdown();
expect(await t.send([event])).toBe(true);
expect(beaconCalls).toBe(0);
expect(t.isActive()).toBe(false);
});
it("uses navigator.sendBeacon when none injected", {
timeout: 5000,
}, async () => {
// happy-dom exposes navigator.sendBeacon as a function returning
// true. The default path simply hits that.
const original = (
navigator as unknown as {
sendBeacon?: (u: string, b: BodyInit | null) => boolean;
}
).sendBeacon;
(
navigator as unknown as {
sendBeacon: (u: string, b: BodyInit | null) => boolean;
}
).sendBeacon = (_u, _b) => true;
try {
const t = new BeaconTransport({ endpoint: "/x" });
expect(await t.send([event])).toBe(true);
} finally {
(
navigator as unknown as {
sendBeacon?: typeof original;
}
).sendBeacon = original;
}
});
it("default sendBeacon returns false when navigator is missing", {
timeout: 5000,
}, async () => {
const realNav = globalThis.navigator;
Object.defineProperty(globalThis, "navigator", {
value: undefined,
configurable: true,
});
try {
const t = new BeaconTransport({
endpoint: "/x",
fallbackToFetch: false,
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.BEACON_REJECTED,
});
} finally {
Object.defineProperty(globalThis, "navigator", {
value: realNav,
configurable: true,
});
}
});
it("default sendBeacon returns false when navigator lacks the method", {
timeout: 5000,
}, async () => {
const realNav = globalThis.navigator;
Object.defineProperty(globalThis, "navigator", {
value: {} as Navigator,
configurable: true,
});
try {
const t = new BeaconTransport({
endpoint: "/x",
fallbackToFetch: false,
});
await expect(t.send([event])).rejects.toMatchObject({
code: ANALYTICS_BROWSER_CODES.BEACON_REJECTED,
});
} finally {
Object.defineProperty(globalThis, "navigator", {
value: realNav,
configurable: true,
});
}
});
});

View File

@@ -0,0 +1,84 @@
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "../error-codes/error-codes.js";
import type {
AnalyticsEvent,
BeaconTransportOptions,
Transport,
} from "../types/analytics.types.js";
function defaultSendBeacon(url: string, body: BodyInit | null): boolean {
if (
typeof navigator === "undefined" ||
typeof navigator.sendBeacon !== "function"
) {
return false;
}
return navigator.sendBeacon(url, body);
}
/**
* `BeaconTransport` — delivers a batch via `navigator.sendBeacon`,
* which the browser is contractually required to attempt during
* `visibilitychange → hidden` and `pagehide`. When `sendBeacon` returns
* `false` (the body is too large, the user rejected the beacon, etc.)
* the transport optionally falls back to `fetch` with `keepalive: true`
* or throws `AnalyticsBrowserError(BEACON_REJECTED)`.
*/
export class BeaconTransport implements Transport {
private readonly endpoint: string;
private readonly fallbackToFetch: boolean;
private readonly sendBeaconFn: (
url: string,
body: BodyInit | null
) => boolean;
private readonly fetchFn: typeof fetch;
private active = true;
public constructor(options: BeaconTransportOptions) {
if (!options.endpoint) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.INVALID_OPTIONS,
"BeaconTransport requires a non-empty endpoint"
);
}
this.endpoint = options.endpoint;
this.fallbackToFetch = options.fallbackToFetch ?? true;
this.sendBeaconFn = options.sendBeaconFn ?? defaultSendBeacon;
this.fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis);
}
public async send(events: readonly AnalyticsEvent[]): Promise<boolean> {
if (!this.active || events.length === 0) {
return true;
}
const body = JSON.stringify({ events });
const ok = this.sendBeaconFn(this.endpoint, body);
if (ok) {
return true;
}
if (!this.fallbackToFetch) {
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.BEACON_REJECTED,
"navigator.sendBeacon returned false",
{ context: { endpoint: this.endpoint } }
);
}
const response = await this.fetchFn(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
});
return response.ok;
}
public async shutdown(): Promise<void> {
this.active = false;
}
public isActive(): boolean {
return this.active;
}
}

View File

@@ -0,0 +1,2 @@
export { BatchTransport } from "./batch.js";
export { BeaconTransport } from "./beacon.js";

View File

@@ -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`.

View File

@@ -0,0 +1,160 @@
import type { EventMap } from "@suaveplan/events";
import type { Clock, IdGenerator, TimersLike } from "@suaveplan/types";
export type { Clock, IdGenerator, TimersLike };
/**
* A single analytics event captured by the tracker.
*
* Fields are immutable; the tracker mints new objects for every `track()`
* / `page()` / `identify()` call so downstream consumers (transports,
* scrubbers, samplers, the event bus) can safely retain references.
*/
export interface AnalyticsEvent {
readonly id: string;
readonly name: string;
readonly properties: Record<string, unknown>;
readonly timestamp: string;
readonly sessionId: string;
readonly anonymousId: string;
readonly userId?: string;
}
/**
* Reason an event was dropped on the consent / sampling / queue path.
*/
export type DropReason =
| "consent"
| "sampling"
| "queue_full"
| "shutdown"
| "invalid";
/**
* Strongly-typed lifecycle event map for `EventBus<AnalyticsEventMap>`.
*
* Each payload is a single object — the genesis `@suaveplan/events`
* substrate uses single-payload `emit(type, payload)`, not tuple
* payloads.
*/
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>;
}
/**
* Transport interface — every concrete transport (HTTP, Beacon, in-test
* fake) implements this. `send` resolves to `true` on success and `false`
* when the batch was rejected after retries (4xx short-circuit). It
* throws when the failure is unrecoverable (timeout, beacon rejection,
* configuration error).
*/
export interface Transport {
send(events: readonly AnalyticsEvent[]): Promise<boolean>;
shutdown(): Promise<void>;
}
/**
* Pluggable durable-queue persistence — bundled implementations:
* `MemoryStorageBackend` and `WebStorageBackend`. An IndexedDB adapter
* slots into the same SPI once `@suaveplan/storage-browser` lands.
*/
export interface StorageBackend {
enqueue(events: readonly AnalyticsEvent[]): Promise<void>;
dequeueAll(): Promise<AnalyticsEvent[]>;
clear(): Promise<void>;
getDepth(): Promise<number>;
}
/**
* Consent policy SPI — the tracker calls `isAllowed` before enqueueing.
* Built-ins: `AllowAllPolicy` (default) and `DoNotTrackPolicy`.
*/
export interface ConsentPolicy {
isAllowed(event: AnalyticsEvent): boolean;
}
/**
* Sampling SPI — built-in: `HeadSampler` with per-event-name override.
*/
export interface Sampler {
shouldSample(event: AnalyticsEvent): boolean;
}
/**
* Strategy applied to a single PII field path.
*/
export type ScrubStrategy = "redact" | "hash" | "drop";
/**
* Rule consumed by `scrubPII` — `field` is a dotted path into
* `event.properties` (`"user.email"`).
*/
export interface ScrubRule {
readonly field: string;
readonly strategy: ScrubStrategy;
}
/**
* Static snapshot of a `SessionManager`'s state.
*/
export interface SessionInfo {
readonly id: string;
readonly startedAt: string;
readonly pageViews: number;
readonly duration: number;
readonly referrer: string;
readonly isActive: boolean;
}
/**
* Options for `SessionManager`.
*/
export interface SessionOptions {
readonly timeoutMs?: number;
readonly referrer?: string;
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
/**
* Options for `BatchTransport`.
*/
export interface BatchTransportOptions {
readonly endpoint: string;
readonly maxRetries?: number;
readonly retryDelay?: number;
readonly timeoutMs?: number;
readonly fetchFn?: typeof fetch;
readonly timers?: TimersLike;
}
/**
* Options for `BeaconTransport`.
*/
export interface BeaconTransportOptions {
readonly endpoint: string;
readonly fallbackToFetch?: boolean;
readonly sendBeaconFn?: (url: string, body: BodyInit | null) => boolean;
readonly fetchFn?: typeof fetch;
}
/**
* Options for `AnalyticsTracker`.
*/
export interface TrackerOptions {
readonly batchSize?: number;
readonly flushInterval?: number;
readonly consentPolicy?: ConsentPolicy;
readonly sampler?: Sampler;
readonly scrubRules?: readonly ScrubRule[];
readonly storage?: StorageBackend;
readonly clock?: Clock;
readonly timers?: TimersLike;
readonly idGenerator?: IdGenerator;
}

View File

@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { AnalyticsTracker } from "../src/tracker/tracker.js";
import { BatchTransport } from "../src/transport/batch.js";
const noopTimers: TimersLike = {
setTimer: () => 0,
clearTimer: () => {},
};
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
yield* walk(full);
} else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) {
yield full;
}
}
}
describe("integration", () => {
it("tracker → batch transport → in-process endpoint round-trip", {
timeout: 5000,
}, async () => {
const observed: Array<{ events: unknown[] }> = [];
const fetchFn: typeof fetch = async (_url, init) => {
const body = init?.body as string;
observed.push(JSON.parse(body) as { events: unknown[] });
return new Response("", { status: 200 });
};
const transport = new BatchTransport({
endpoint: "/api/analytics",
fetchFn,
});
const tracker = new AnalyticsTracker(transport, {
batchSize: 1,
timers: noopTimers,
});
const seen: string[] = [];
tracker.events.on("track", () => seen.push("track"));
tracker.events.on("flush", ({ success }) =>
seen.push(`flush:${String(success)}`)
);
tracker.track("e");
await tracker.flush();
expect(observed.length).toBe(1);
expect((observed[0]?.events?.[0] as { name: string }).name).toBe("e");
expect(seen[0]).toBe("track");
expect(seen[seen.length - 1]).toBe("flush:true");
});
it("src/ contains no node: imports", { timeout: 5000 }, () => {
const srcDir = join(__dirname, "..", "src");
for (const file of walk(srcDir)) {
const text = readFileSync(file, "utf8");
expect(text).not.toMatch(/from\s+["']node:/);
}
});
});

View File

@@ -0,0 +1,41 @@
/**
* Test setup for @suaveplan/analytics-browser.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Also
* polyfills `globalThis.crypto` for happy-dom test environments where the
* subtle crypto provider may be missing.
*/
import { webcrypto } from "node:crypto";
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined || g.crypto.subtle === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics(): Promise<unknown> {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,54 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/analytics-browser",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"tracker/index": "src/tracker/index.ts",
"transport/index": "src/transport/index.ts",
"session/index": "src/session/index.ts",
"consent/index": "src/consent/index.ts",
"pii/index": "src/pii/index.ts",
"sampling/index": "src/sampling/index.ts",
"storage/index": "src/storage/index.ts",
"schema/index": "src/schema/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"types/analytics.types": "src/types/analytics.types.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/constants/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});