Initial Commit
This commit is contained in:
17
packages/observability/monitor/README.md
Normal file
17
packages/observability/monitor/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# `monitor`
|
||||
|
||||
Logging, health checks, audit logging, and analytics instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `monitor`
|
||||
|
||||
5 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/analytics-browser`](./analytics-browser/README.md) | Browser-tier client-side product analytics — `AnalyticsTracker`, `BatchTransport`, `BeaconTransport`, and `SessionManager` with consent gate, PII scrubber, hea… |
|
||||
| [`@suaveplan/audit-logging`](./audit-logging/README.md) | Tamper-proof, GDPR-compliant audit logging for Suaveplan. Provides a cryptographic chain-integrity engine, pluggable storage adapters, GDPR data export, config… |
|
||||
| [`@suaveplan/health-check`](./health-check/README.md) | Server-tier health-check core for `/health`, `/ready`, `/live` endpoints with dependency checks (HTTP, TCP, custom), aggregated status, caching, and typed life… |
|
||||
| [`@suaveplan/logger`](./logger/README.md) | Enterprise-grade structured logger for Suaveplan. Provides a full pipeline: level filtering → OTel trace enrichment → sampling → redaction → sinks. Supports mu… |
|
||||
| [`@suaveplan/opentelemetry`](./opentelemetry/README.md) | OpenTelemetry SDK setup and configuration helpers for Suaveplan. Provides batteries-included OTel initialization helpers, resource detection, ergonomic span/me… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/observability/monitor/_intro.md
Normal file
1
packages/observability/monitor/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Logging, health checks, audit logging, and analytics instrumentation.
|
||||
204
packages/observability/monitor/analytics-browser/README.md
Normal file
204
packages/observability/monitor/analytics-browser/README.md
Normal 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.
|
||||
|
||||
124
packages/observability/monitor/analytics-browser/package.json
Normal file
124
packages/observability/monitor/analytics-browser/package.json
Normal 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"]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# `consent.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `ConsentPolicy` SPI bundled with `@suaveplan/analytics-browser`. The tracker calls `policy.isAllowed(event)` before any sampling, scrubbing, or queueing decision, so a `false` return short-circuits the entire pipeline and emits a `drop` event with reason `consent`. Two built-in policies ship with the package: `AllowAllPolicy` (default) and `DoNotTrackPolicy` (honours `navigator.doNotTrack === "1"`). Downstream consumers can implement their own policy — e.g. one that consults a stored cookie-banner preference — by implementing the interface from `@suaveplan/analytics-browser/types`.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy navigator lookup — both policies are constructable in SSR / Node contexts where `navigator` is undefined; the policy simply permits the event in that case.
|
||||
- Zero state — both built-ins are stateless and reentrant.
|
||||
- Strict equality on `"1"` — matches the W3C Tracking Preference Expression spec; values `"0"` or `"unspecified"` permit tracking.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
|
||||
const tracker = new AnalyticsTracker(transport, {
|
||||
consentPolicy: new DoNotTrackPolicy(),
|
||||
});
|
||||
tracker.track("page_loaded"); // dropped if navigator.doNotTrack === "1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnalyticsEvent,
|
||||
ConsentPolicy,
|
||||
} from "@suaveplan/analytics-browser/types";
|
||||
|
||||
class CookieBannerPolicy implements ConsentPolicy {
|
||||
public constructor(private readonly read: () => "granted" | "denied") {}
|
||||
|
||||
public isAllowed(_event: AnalyticsEvent): boolean {
|
||||
return this.read() === "granted";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AllowAllPolicy implements ConsentPolicy` — `isAllowed(event) → true`.
|
||||
- `class DoNotTrackPolicy implements ConsentPolicy` — returns `false` iff `typeof navigator !== "undefined" && navigator.doNotTrack === "1"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DoNotTrackPolicy` reads `navigator.doNotTrack` on every call rather than caching at construction time. This is deliberate: the spec lets the user toggle the preference at runtime in some browsers (notably in private windows), and caching would freeze the decision at construction. The cost is a single property read per event — negligible compared with the JSON serialisation that follows on the happy path. Both classes pass their `event` argument with a leading underscore name to signal it is intentionally unused; lint rules permit underscore-prefixed unused parameters.
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AllowAllPolicy, DoNotTrackPolicy } from "./consent.js";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,82 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and typed error class for `@suaveplan/analytics-browser`. The registry uses `@suaveplan/error`'s `defineCodes` so every value is namespaced under the `ANALYTICS_BROWSER.` prefix and resolves through the shared error-resolution pipeline (i18n, OTel span events, structured JSON). Consumers SHOULD discriminate on `error.code` rather than `instanceof` checks so the namespace survives module boundaries and runtime serialisation. The `AnalyticsBrowserError` class is the only typed error thrown by the package and extends `SuaveplanError<AnalyticsBrowserCode>` so a `switch (err.code)` block narrows against the literal union.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight canonical reasons covering Zod-boundary validation, transport timeout / failure, durable queue overflow, missing host storage, tracker-after-shutdown rejection, invalid analytics-event payload, and beacon rejection.
|
||||
- Code values are TypeScript `as const` literals so the resulting union is fully inferred.
|
||||
- `AnalyticsBrowserError` exposes a public constructor (the base `SuaveplanError` constructor is protected) carrying `cause` and `context` for full forensic detail.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ANALYTICS_BROWSER_CODES,
|
||||
AnalyticsBrowserError,
|
||||
} from "@suaveplan/analytics-browser/error-codes";
|
||||
|
||||
try {
|
||||
await transport.send(events);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AnalyticsBrowserError &&
|
||||
err.code === ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT
|
||||
) {
|
||||
console.warn("retry on next flush", err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ANALYTICS_BROWSER_CODES,
|
||||
type AnalyticsBrowserCode,
|
||||
AnalyticsBrowserError,
|
||||
} from "@suaveplan/analytics-browser/error-codes";
|
||||
|
||||
function explain(code: AnalyticsBrowserCode): string {
|
||||
switch (code) {
|
||||
case ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT:
|
||||
return "request exceeded timeoutMs";
|
||||
case ANALYTICS_BROWSER_CODES.TRANSPORT_FAILED:
|
||||
return "backend returned 4xx";
|
||||
case ANALYTICS_BROWSER_CODES.QUEUE_FULL:
|
||||
return "durable queue is at capacity";
|
||||
case ANALYTICS_BROWSER_CODES.STORAGE_UNAVAILABLE:
|
||||
return "host Storage is missing";
|
||||
case ANALYTICS_BROWSER_CODES.SHUTDOWN:
|
||||
return "tracker has been shut down";
|
||||
case ANALYTICS_BROWSER_CODES.INVALID_EVENT:
|
||||
return "event failed Zod validation";
|
||||
case ANALYTICS_BROWSER_CODES.INVALID_OPTIONS:
|
||||
return "constructor options failed validation";
|
||||
case ANALYTICS_BROWSER_CODES.BEACON_REJECTED:
|
||||
return "navigator.sendBeacon returned false";
|
||||
}
|
||||
}
|
||||
|
||||
throw new AnalyticsBrowserError(
|
||||
ANALYTICS_BROWSER_CODES.QUEUE_FULL,
|
||||
"watermark exceeded",
|
||||
{ context: { depth: 1024 } },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ANALYTICS_BROWSER_CODES` — `Readonly<Record<Reason, "ANALYTICS_BROWSER.<Reason>">>`. The keys are the literal reason names; the values are namespaced strings.
|
||||
- `type AnalyticsBrowserCode` — string-literal union of all eight code values.
|
||||
- `class AnalyticsBrowserError extends SuaveplanError<AnalyticsBrowserCode>` — `new AnalyticsBrowserError(code, message, { cause?, context? })`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is called eagerly at module import so the registry is populated by the time any tracker or transport asks `@suaveplan/error` to resolve a code. The namespace string `ANALYTICS_BROWSER` is unique across the project registry — no other package owns it. Adding a new code is a backward-compatible change as long as the existing reasons remain stable; deletions or renames require an spec change.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { AnalyticsBrowserCode } from "./error-codes.js";
|
||||
export {
|
||||
ANALYTICS_BROWSER_CODES,
|
||||
AnalyticsBrowserError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,51 @@
|
||||
# `id.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal cryptographically-random ID generator shared by the analytics tracker, the session manager, and any consumer that injects a default `IdGenerator`. Centralising the implementation removes the duplicate `generateId()` that lived in both `tracker.ts` and `session.ts` upstream and guarantees that anonymous IDs, event IDs, and session IDs share a single entropy source. The output is a 32-character hex string drawn from `crypto.getRandomValues`, suitable for use as a public identifier without leaking host state.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of randomness — `globalThis.crypto.getRandomValues` directly, no Math.random fallback.
|
||||
- Pure synchronous return; no `Promise` overhead on the hot path.
|
||||
- Hex-encoded so the ID is URL-safe, log-safe, and 1:1 with the byte length (16 bytes → 32 chars).
|
||||
- Re-exported through `src/internal/id/index.ts` so the rest of the package never imports from a flat module.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { generateId } from "@suaveplan/analytics-browser/tracker";
|
||||
// ↑ generateId is internal; in tests / docs import from the barrel that
|
||||
// happens to re-export it. Production code typically lets the tracker
|
||||
// default-construct its own IDs.
|
||||
|
||||
const anonymousId = generateId();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { IdGenerator } from "@suaveplan/types";
|
||||
import { generateId } from "./internal/id/id.js";
|
||||
|
||||
// Wrap the function-shape IdGenerator from @suaveplan/types around the
|
||||
// shared implementation — useful when a downstream consumer wants to
|
||||
// inject a deterministic counter in tests.
|
||||
const realIds: IdGenerator = generateId;
|
||||
const fakeIds: IdGenerator = (() => {
|
||||
let n = 0;
|
||||
return () => `evt-${(++n).toString().padStart(32, "0")}`;
|
||||
})();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `generateId(): string` — returns a fresh 32-character lowercase hex string. Throws synchronously only if `globalThis.crypto.getRandomValues` is missing or throws (e.g. when the host crypto API is intentionally disabled).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 16-byte entropy budget gives 128 bits — the same size as a UUIDv4 — without the dash separators or version nibbles. Hex encoding is the simplest reversible representation and is cheap (~16 string concatenations) compared with base32 or base64. The byte length lives in `constants/index.ts` so a future opt-in to 24-byte IDs is a one-line constant change rather than a refactor across call sites.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { generateId } from "./id.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { scrubPII } from "./pii.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# `pii.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEvent` plus an ordered array of `ScrubRule`s and returns a new event with the affected `properties` either redacted, replaced with a deterministic fingerprint, or dropped entirely. Scrubbing runs on the tracker hot path between consent / sampling decisions and the queue write, so it must be synchronous, side-effect-free, and never throw on valid input. The function clones `properties` via `structuredClone` so the original event remains immutable.
|
||||
|
||||
## Features
|
||||
|
||||
- Three strategies: `"redact"` (replace value with `"[REDACTED]"`), `"hash"` (replace with FNV-1a fingerprint), `"drop"` (delete the key).
|
||||
- Dotted-path field resolution — `"user.email"` walks into nested objects.
|
||||
- Order-preserving — rules apply in the order given so later rules see the effect of earlier ones.
|
||||
- Pure / synchronous — no async, no I/O, no `subtle.digest`.
|
||||
- Idempotent — the same input + rules always yields the same output (including the fingerprint).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const safe = scrubPII(event, [
|
||||
{ field: "email", strategy: "redact" },
|
||||
{ field: "ssn", strategy: "drop" },
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ScrubRule } from "@suaveplan/analytics-browser/types";
|
||||
import { scrubPII } from "@suaveplan/analytics-browser/pii";
|
||||
|
||||
const piiRules: readonly ScrubRule[] = [
|
||||
{ field: "user.email", strategy: "redact" },
|
||||
{ field: "user.phone", strategy: "hash" },
|
||||
{ field: "payment.cardNumber", strategy: "drop" },
|
||||
];
|
||||
|
||||
function safeTrack(name: string, properties: Record<string, unknown>) {
|
||||
const wrapped = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: "s",
|
||||
anonymousId: "a",
|
||||
};
|
||||
return scrubPII(wrapped, piiRules);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `scrubPII(event: AnalyticsEvent, rules: readonly ScrubRule[]): AnalyticsEvent` — returns a fresh event with scrubbed `properties`. Returns the original event reference when `rules.length === 0`.
|
||||
- Rule shape: `{ field: string; strategy: "redact" | "hash" | "drop" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The fingerprint is FNV-1a 32-bit, encoded as 8 lowercase hex chars. FNV-1a was chosen over Web Crypto's `subtle.digest` because the latter is asynchronous, which would force the entire scrub pipeline onto a Promise chain and complicate the tracker's hot path. FNV-1a is NOT a cryptographic hash — it is a redaction that preserves cross-event correlation (the same value always fingerprints to the same hex string) without revealing the value itself. If a stronger guarantee is required, downstream code SHOULD scrub via a custom rule sequence and a pre-hashed input value, or implement its own `Sampler`-style hook.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HeadSampler } from "./sampling.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
# `sampling.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements head-based sampling for `@suaveplan/analytics-browser`. The tracker calls `sampler.shouldSample(event)` after the consent gate but before the scrubber so events that will be dropped never pay the cost of property scrubbing or queue persistence. `HeadSampler` accepts a base sample rate in `[0, 1]` plus an optional `Record<eventName, rate>` override map so common events (e.g. `page`) can be captured at a different rate than rare events (e.g. `error_reported`).
|
||||
|
||||
## Features
|
||||
|
||||
- Per-event-name overrides — `new HeadSampler(0.1, { error_reported: 1 })` keeps every error while sampling everything else at 10 %.
|
||||
- Strict clamping — rates `<= 0` always drop, rates `>= 1` always keep; `NaN` clamps to 0.
|
||||
- Injectable random source — the constructor accepts a `random: () => number` seam so tests can drive deterministic decisions without monkey-patching `Math.random`.
|
||||
- Stateless — every call is independent; safe to share a sampler across many trackers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
|
||||
const sampler = new HeadSampler(0.25); // keep 25 % of events
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
|
||||
// 1 % sampling for verbose page-view events, 100 % for clicks and errors.
|
||||
const sampler = new HeadSampler(0.01, {
|
||||
clicked: 1,
|
||||
error_reported: 1,
|
||||
});
|
||||
|
||||
// In tests, inject a deterministic counter so behaviour is reproducible.
|
||||
let n = 0;
|
||||
const deterministic = new HeadSampler(0.5, {}, () => {
|
||||
const sequence = [0.4, 0.6, 0.2, 0.8];
|
||||
return sequence[n++ % sequence.length] as number;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class HeadSampler implements Sampler`
|
||||
- `new HeadSampler(rate, overrides?, random?)`
|
||||
- `rate: number` — base rate in `[0, 1]`. Values outside this range clamp.
|
||||
- `overrides?: Readonly<Record<string, number>>` — per-event-name rate. Each entry clamps independently.
|
||||
- `random?: () => number` — random source; defaults to `Math.random`.
|
||||
- `shouldSample(event): boolean` — `true` keeps the event, `false` drops it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The clamp logic guards against floating-point drift and pathological caller input (e.g. accidentally passing `rate=2`). NaN clamps to 0 rather than 1 so a misconfigured environment variable does not silently flood the backend. The injected random source enables deterministic testing without resorting to `vi.stubGlobal` (banned by §2 of `AGENTS.md`).
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# `analytics-event-schema.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical Zod schema for `AnalyticsEvent` and a `validateAnalyticsEvent` parse helper. The schema lets consumers validate untrusted payloads (iframe postMessage bridges, browser extension messages, recovered IndexedDB rows) before handing them to the tracker. The helper wraps `safeParse` so the boundary failure mode is a typed `AnalyticsBrowserError(INVALID_EVENT)` carrying the full Zod issue list on `error.context.issues` — no raw `ZodError` leaks past the boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for the on-wire and in-memory shape of an analytics event.
|
||||
- Required: `id`, `name`, `timestamp`, `sessionId`, `anonymousId`, `properties` (record). Optional: `userId`.
|
||||
- Boundary-failure translation: every Zod issue is collected into `error.context.issues` so observability tooling can render the full failure tree.
|
||||
- Schema is exported directly so downstream packages can `.merge` / `.extend` to add domain-specific shape (e.g. an e-commerce analytics extension).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateAnalyticsEvent } from "@suaveplan/analytics-browser/schema";
|
||||
|
||||
const safe = validateAnalyticsEvent(unknownPayload);
|
||||
tracker.track(safe.name, safe.properties);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { AnalyticsEventSchema } from "@suaveplan/analytics-browser/schema";
|
||||
|
||||
// Domain-specific extension — every commerce event additionally carries
|
||||
// a non-empty `cartId`. Composition stays within the Zod model so the
|
||||
// .issues path stays intact when validation fails.
|
||||
const CommerceEventSchema = AnalyticsEventSchema.extend({
|
||||
properties: z
|
||||
.record(z.string(), z.unknown())
|
||||
.refine((p) => typeof p.cartId === "string" && p.cartId.length > 0, {
|
||||
message: "missing cartId",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AnalyticsEventSchema: z.ZodType<AnalyticsEvent>` — the raw Zod schema.
|
||||
- `validateAnalyticsEvent(input: unknown): AnalyticsEvent` — parses and returns; throws `AnalyticsBrowserError(INVALID_EVENT)` on failure with `error.context.issues` exposing the Zod issue array.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema deliberately uses `z.string().min(1)` rather than `z.string()` for every identifier so a producer cannot bypass validation by sending an empty string. `properties` uses `z.record(z.string(), z.unknown())` rather than a stricter shape because tracker callers attach arbitrary metadata; if a downstream consumer needs a stricter contract they SHOULD `.extend` the schema as shown above. The helper does not log on failure — that is the caller's decision — and never swallows errors silently.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
AnalyticsEventSchema,
|
||||
validateAnalyticsEvent,
|
||||
} from "./analytics-event-schema.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { SessionManager } from "./session.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# `session.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Tracks the lifecycle of a single browser session for `@suaveplan/analytics-browser`. The `SessionManager` owns the session ID, the start time, the page-view counter, the captured `document.referrer`, and the inactivity-timeout state machine. Consumers attach it to a tracker (`tracker.setSessionId(session.getSessionId())`) and call `recordPageView()` / `touch()` as user interaction occurs. The class is fully clock-injectable so tests can drive the inactivity timeout without sleeping.
|
||||
|
||||
## Features
|
||||
|
||||
- Cryptographically-random session ID (defaults to the package's shared `generateId`).
|
||||
- Inactivity timeout — `isActive()` returns `false` after `timeoutMs` of silence; the next `recordPageView()` is a no-op until `renew()` is called.
|
||||
- Manual `renew()` — resets the ID, start time, page-view count, and active flag (used on logout / login or app-managed session boundary events).
|
||||
- Injectable `clock`, `idGenerator`, and `referrer` for deterministic tests and SSR.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SessionManager } from "@suaveplan/analytics-browser/session";
|
||||
|
||||
const session = new SessionManager({ timeoutMs: 30 * 60 * 1000 });
|
||||
session.recordPageView();
|
||||
const info = session.getInfo();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SessionManager } from "@suaveplan/analytics-browser/session";
|
||||
|
||||
let now = 0;
|
||||
const session = new SessionManager({
|
||||
timeoutMs: 60_000,
|
||||
clock: () => now,
|
||||
idGenerator: () => "test-session",
|
||||
referrer: "https://example.com",
|
||||
});
|
||||
|
||||
now = 30_000;
|
||||
session.recordPageView();
|
||||
now = 120_000; // exceeds timeoutMs since last activity
|
||||
session.isActive(); // → false
|
||||
session.renew(); // mint a fresh session id
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionManager`
|
||||
- `new SessionManager(options?)` — `options: SessionOptions { timeoutMs?, referrer?, clock?, idGenerator? }`
|
||||
- `getSessionId(): string`
|
||||
- `getReferrer(): string`
|
||||
- `getPageViews(): number`
|
||||
- `recordPageView(): void` — no-op when session is inactive
|
||||
- `touch(): void` — resets the inactivity clock
|
||||
- `getDuration(): number` — milliseconds since `startedAt`
|
||||
- `isActive(): boolean` — auto-expires past `timeoutMs`
|
||||
- `end(): void` — explicitly terminates the session
|
||||
- `renew(): string` — mints a fresh session id, zeros page views, resets the inactivity clock
|
||||
- `getInfo(): SessionInfo` — `{ id, startedAt, pageViews, duration, referrer, isActive }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getDuration` returns the live duration when the session is active, otherwise the duration up to the last activity timestamp. This keeps the duration stable after `end()` is called — repeated `getDuration()` calls on an ended session return the same value rather than drifting upward with wall-clock time. The `document.referrer` lookup is guarded by `typeof document !== "undefined"` so SSR contexts default to `""` rather than throwing.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
MemoryStorageBackend,
|
||||
WebStorageBackend,
|
||||
type WebStorageBackendOptions,
|
||||
} from "./storage.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
# `storage.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `StorageBackend` SPI bundled with `@suaveplan/analytics-browser`. Two backends ship: `MemoryStorageBackend` (default) and `WebStorageBackend` (DOM `Storage`-backed JSON queue with an optional `maxBytes` watermark). The tracker writes every accepted event into both an in-memory FIFO and the configured backend; the backend supplies durability across page reloads or transport outages. An IndexedDB adapter will slot into the same SPI once `@suaveplan/storage-browser` lands.
|
||||
|
||||
## Features
|
||||
|
||||
- `MemoryStorageBackend` — zero-config FIFO in a single array.
|
||||
- `WebStorageBackend` — `localStorage` / `sessionStorage` / any `Storage` implementation, JSON-encoded, custom key, byte-count watermark with `QUEUE_FULL` ejection.
|
||||
- Defensive read — corrupted or non-array payloads are ignored rather than throwing.
|
||||
- Constructor validation — `WebStorageBackend({ storage: null })` throws `STORAGE_UNAVAILABLE` at construction time, before the tracker ever runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
MemoryStorageBackend,
|
||||
WebStorageBackend,
|
||||
} from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
// SSR / tests
|
||||
const backend = new MemoryStorageBackend();
|
||||
|
||||
// Production browser
|
||||
const backend = new WebStorageBackend({ storage: localStorage });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
// Tight watermark for low-storage devices.
|
||||
const backend = new WebStorageBackend({
|
||||
storage: localStorage,
|
||||
key: "@my-app/analytics",
|
||||
maxBytes: 64_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface StorageBackend` — `enqueue(events) → Promise<void>`, `dequeueAll() → Promise<AnalyticsEvent[]>`, `clear() → Promise<void>`, `getDepth() → Promise<number>`.
|
||||
- `class MemoryStorageBackend implements StorageBackend` — no constructor arguments.
|
||||
- `class WebStorageBackend implements StorageBackend` — `new WebStorageBackend({ storage, key?, maxBytes? })`. Throws `AnalyticsBrowserError(STORAGE_UNAVAILABLE)` when `storage` is falsy and `AnalyticsBrowserError(QUEUE_FULL)` on enqueue overflow.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WebStorageBackend` writes the full JSON array on every enqueue rather than appending — this keeps the on-disk shape trivially reversible and means a corrupted last-write fails-closed (the parse returns `[]`, the next flush drains nothing). The cost is an O(n) write per enqueue; for typical analytics traffic (10–100 events per flush) this is negligible. The byte-count check is performed against the encoded payload length rather than the array length so a single oversized event cannot bypass the watermark by dragging in many other events.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
batchSizeHistogram,
|
||||
eventsDroppedCounter,
|
||||
eventsFlushedCounter,
|
||||
eventsTrackedCounter,
|
||||
flushLatencyHistogram,
|
||||
recordDropped,
|
||||
recordEventTracked,
|
||||
recordFlush,
|
||||
} from "./telemetry.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# `telemetry.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the OpenTelemetry instruments emitted by `@suaveplan/analytics-browser`. Three counters cover the accept / flush / drop paths and two histograms cover batch sizes and flush latencies. Every instrument is bound lazily through `metrics.getMeter(METER_NAME)` so production code that never installs a `MeterProvider` pays only a single property-read cost per recorded event — the OTel no-op meter discards everything cheaply.
|
||||
|
||||
## Features
|
||||
|
||||
- `eventsTrackedCounter{name}` — accepted events handed to the queue.
|
||||
- `eventsFlushedCounter{status}` — flush attempts; `status: "success" | "failure"`.
|
||||
- `eventsDroppedCounter{reason}` — dropped events; `reason: "consent" | "sampling" | "queue_full" | "shutdown" | "invalid"`.
|
||||
- `batchSizeHistogram` — observed flush sizes (event count).
|
||||
- `flushLatencyHistogram` — wall-clock duration of a single `transport.send`, in milliseconds.
|
||||
- Helper `record*` functions encapsulate the tag schema so call sites never assemble tag bags inline.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordEventTracked } from "@suaveplan/analytics-browser/telemetry";
|
||||
recordEventTracked("clicked");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
eventsTrackedCounter,
|
||||
recordFlush,
|
||||
} from "@suaveplan/analytics-browser/telemetry";
|
||||
|
||||
// Add custom tags to the underlying instrument when integrating with a
|
||||
// product-specific OTel pipeline.
|
||||
eventsTrackedCounter.add(1, {
|
||||
name: "clicked",
|
||||
surface: "checkout-button",
|
||||
});
|
||||
|
||||
recordFlush("success", batch.length, performance.now() - start);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `eventsTrackedCounter: Counter` — backing instrument.
|
||||
- `eventsFlushedCounter: Counter`
|
||||
- `eventsDroppedCounter: Counter`
|
||||
- `batchSizeHistogram: Histogram`
|
||||
- `flushLatencyHistogram: Histogram`
|
||||
- `recordEventTracked(name: string): void`
|
||||
- `recordFlush(status, eventCount, latencyMs): void`
|
||||
- `recordDropped(reason: DropReason): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter name follows the convention of `@suaveplan/<package>` so dashboards and alerts can filter on a single attribute. Histograms intentionally do not declare explicit bucket boundaries — the SDK picks reasonable defaults, and downstream observability platforms (Honeycomb / Grafana / Datadog) typically re-bucket on ingest anyway. The `batch_size` histogram has no unit; the `flush_latency_ms` histogram declares `ms`.
|
||||
@@ -0,0 +1,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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AnalyticsTracker } from "./tracker.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `tracker.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AnalyticsTracker` is the central orchestrator of `@suaveplan/analytics-browser`. It accepts user-facing events through `track()` / `page()` / `identify()`, runs each through a deterministic consent → sampling → PII-scrub pipeline, persists accepted events into both an in-memory FIFO and an injectable `StorageBackend`, and flushes batched events through the supplied `Transport`. Lifecycle observability is exposed through a typed `EventBus<AnalyticsEventMap>` on `tracker.events`. Every browser-API touch (timers, clock, IDs, storage) is reached through an injectable seam, so the tracker is fully testable without `vi.stubGlobal` or browser-specific mocks.
|
||||
|
||||
## Features
|
||||
|
||||
- Six-channel typed lifecycle bus — `track`, `pageview`, `identify`, `flush`, `drop`, `error`, `shutdown`.
|
||||
- Single-flight `flush()` — concurrent calls share the in-flight promise.
|
||||
- Auto-flush via injectable `TimersLike` — `flushInterval=0` disables the interval entirely.
|
||||
- Durable queue round-trip — persisted events are restored via `restore()` and cleared after a successful flush.
|
||||
- OTel telemetry — every accept / drop / flush emits the bundled counters/histograms.
|
||||
- Single-payload event bus — payload shape `{ event }` / `{ reason, event? }` etc. for ergonomic destructuring.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const tracker = new AnalyticsTracker(
|
||||
new BatchTransport({ endpoint: "/api/analytics" }),
|
||||
);
|
||||
|
||||
tracker.identify("user-123");
|
||||
tracker.track("clicked", { surface: "header" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
|
||||
import { BatchTransport, BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
|
||||
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
|
||||
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
|
||||
|
||||
const transport = new BatchTransport({ endpoint: "/api/analytics" });
|
||||
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
|
||||
|
||||
const tracker = new AnalyticsTracker(transport, {
|
||||
consentPolicy: new DoNotTrackPolicy(),
|
||||
sampler: new HeadSampler(0.1, { error_reported: 1 }),
|
||||
scrubRules: [
|
||||
{ field: "email", strategy: "redact" },
|
||||
{ field: "card.number", strategy: "drop" },
|
||||
],
|
||||
storage: new WebStorageBackend({ storage: localStorage }),
|
||||
});
|
||||
|
||||
// Restore any events persisted across reload.
|
||||
await tracker.restore();
|
||||
|
||||
// Unload-safe flush: hand off to the beacon transport.
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
void beacon.send([...tracker.events.constructor.prototype, ...[]]);
|
||||
void tracker.flush();
|
||||
}
|
||||
});
|
||||
|
||||
tracker.events.on("flush", ({ eventCount, success, latencyMs }) => {
|
||||
console.info("flush", { eventCount, success, latencyMs });
|
||||
});
|
||||
|
||||
tracker.events.on("drop", ({ reason, event }) => {
|
||||
console.warn("dropped", reason, event?.name);
|
||||
});
|
||||
|
||||
// Final shutdown — drain and disable.
|
||||
await tracker.shutdown();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class AnalyticsTracker`
|
||||
- `new AnalyticsTracker(transport: Transport, options?: TrackerOptions)`.
|
||||
- `events: EventBus<AnalyticsEventMap>`.
|
||||
- `restore(): Promise<void>` — replay persisted events into the in-memory queue.
|
||||
- `track(name, properties?)`.
|
||||
- `page(url?, properties?)`.
|
||||
- `identify(userId, traits?)`.
|
||||
- `setProperties(properties)`.
|
||||
- `getAnonymousId() / getUserId() / getSessionId() / setSessionId(id) / getQueueSize()`.
|
||||
- `flush(): Promise<boolean>` — single-flight; success drains storage too.
|
||||
- `shutdown(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tracker preserves event order through `flush` failure paths by `unshift`-ing the drained batch back onto the head of the in-memory queue when the transport returns `false` or throws. This keeps the relative ordering of new events that arrived during the flush window. The persistence layer is written-through (every accepted event hits storage), but cleared only on successful flush — a transport that returns `false` leaves the persisted copy intact so a subsequent flush attempt or page reload can still recover the events. The `flushInFlight` promise guard guarantees single-flight semantics; concurrent callers all observe the same outcome.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# `batch.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `BatchTransport` — the HTTP transport used by `AnalyticsTracker` for the steady-state, non-unload flush path. Every call posts a JSON-encoded `{ events }` body to the configured endpoint, wrapped in an `AbortController` whose timeout is armed via the injected `TimersLike`. The transport short-circuits on `4xx` responses (no retry), retries with exponential backoff on `5xx` / network errors, and translates any post-abort failure into a typed `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` so callers can discriminate timeouts from other transport failures.
|
||||
|
||||
## Features
|
||||
|
||||
- `AbortController` + `timeoutMs` on every fetch — guaranteed bounded latency.
|
||||
- `4xx` short-circuit — no retry storm on stable client errors.
|
||||
- Exponential backoff on `5xx` / network — `retryDelay * 2 ** attempt`.
|
||||
- Injectable `fetchFn` and `timers` — every browser-API touch is reachable from tests without DOM globals.
|
||||
- Idempotent `shutdown()` — future `send()` calls are no-ops returning `true`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const transport = new BatchTransport({ endpoint: "/api/analytics" });
|
||||
await transport.send(events);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const transport = new BatchTransport({
|
||||
endpoint: "https://collector.example.com/v1/events",
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: async (url, init) => {
|
||||
const req = new Request(url, init);
|
||||
return fetch(req, { credentials: "include" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BatchTransport implements Transport`
|
||||
- `new BatchTransport(options)` — `options: BatchTransportOptions { endpoint, maxRetries?, retryDelay?, timeoutMs?, fetchFn?, timers? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
|
||||
- `send(events) → Promise<boolean>` — `true` on success, `false` on `4xx` or exhausted retries. Throws `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` when the fetch is aborted by the timeout.
|
||||
- `shutdown() → Promise<void>` — disables further sends.
|
||||
- `isActive() → boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timeout `setTimer` callback aborts the controller with an `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` as the reason. If the host's `fetch` rejects with that exact error, the transport rethrows it as-is. If the fetch rejects with a different error after the signal has aborted (e.g. a `DOMException` synthesised by the runtime), the transport still maps the failure to `TRANSPORT_TIMEOUT` because the signal's aborted state is the authoritative reason. Otherwise the failure is treated as a generic retryable network error and the loop continues until `maxRetries` is exhausted.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# `beacon.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `BeaconTransport` — the page-unload-safe transport used by `AnalyticsTracker.shutdown()` and any consumer wiring a `visibilitychange → hidden` flush. Browsers guarantee that `navigator.sendBeacon` will complete the request even during unload, which makes it the only reliable way to ship a last batch before navigation. When `sendBeacon` returns `false` (body too large, host rejected the beacon, navigator unavailable), the transport optionally falls back to `fetch` with `keepalive: true` or throws a typed `AnalyticsBrowserError(BEACON_REJECTED)`.
|
||||
|
||||
## Features
|
||||
|
||||
- `navigator.sendBeacon` first, `fetch keepalive` fallback.
|
||||
- Injectable `sendBeaconFn` and `fetchFn` so happy-dom and Node test envs can drive both paths.
|
||||
- Graceful degradation — missing `navigator` or missing `sendBeacon` returns `false`, letting the fallback or the typed error path take over.
|
||||
- `fallbackToFetch` defaults to `true` so the steady-state behaviour matches what production code wants.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
|
||||
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
void tracker.flush();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
|
||||
|
||||
const beacon = new BeaconTransport({
|
||||
endpoint: "/api/analytics",
|
||||
fallbackToFetch: false, // strict — surface BEACON_REJECTED to the caller
|
||||
sendBeaconFn: (url, body) => navigator.sendBeacon(url, body),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BeaconTransport implements Transport`
|
||||
- `new BeaconTransport(options)` — `options: BeaconTransportOptions { endpoint, fallbackToFetch?, sendBeaconFn?, fetchFn? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
|
||||
- `send(events) → Promise<boolean>` — `true` on beacon success or `200` fallback fetch. Throws `AnalyticsBrowserError(BEACON_REJECTED)` when `sendBeaconFn` returns `false` and `fallbackToFetch === false`.
|
||||
- `shutdown() → Promise<void>` — disables future sends.
|
||||
- `isActive() → boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`sendBeacon` is synchronous and returns a boolean that signals whether the request was queued, not whether the server received it. The transport treats `true` as success because the browser owns delivery from that point on — there is no observable response to inspect during unload. The fallback fetch sets `keepalive: true` so the same unload-safety guarantee applies; `keepalive` is supported in every browser that ships `sendBeacon`, which keeps the fallback meaningful.
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BatchTransport } from "./batch.js";
|
||||
export { BeaconTransport } from "./beacon.js";
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
94
packages/observability/monitor/audit-logging/README.md
Normal file
94
packages/observability/monitor/audit-logging/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/audit-logging - Tamper-proof, GDPR-compliant audit logging: cryptographic chain-integrity, pluggable storage, retention policy, GDPR export, and a rich event taxonomy
|
||||
|
||||
## Summary
|
||||
|
||||
Tamper-proof, GDPR-compliant audit logging for Suaveplan. Provides a cryptographic chain-integrity engine, pluggable storage adapters, GDPR data export, configurable retention policies, and a rich event taxonomy spanning auth, authorization, data access, system, and GDPR events.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Features
|
||||
|
||||
- **AuditLogger** — orchestrates log, query, verify, export, and retention with injectable dependencies
|
||||
- **Chain integrity** — HMAC-SHA256 signed events linked in a tamper-evident chain via `previousHash`
|
||||
- **MemoryAuditAdapter** — full in-memory storage with rich query filtering for tests and development
|
||||
- **GDPR export** — Article 15/20 compliant `exportGDPRData()` with event summary statistics
|
||||
- **Retention policy** — archive-before-delete with configurable TTL and location
|
||||
- **Zod schemas** — runtime validation for all public types
|
||||
- **OTel instrumentation** — counter `audit.events_logged_total` with `event_type` and `severity` labels
|
||||
- **Event bus integration** — emits `audit:event:logged` after each successful log
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/audit-logging
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { AuditLogger, MemoryAuditAdapter, AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
|
||||
const logger = new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
|
||||
const event = await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "user-123", ipAddress: "203.0.113.1" },
|
||||
target: { type: "session", id: "sess-456" },
|
||||
});
|
||||
|
||||
console.log(event.hash); // hex HMAC-SHA256
|
||||
|
||||
// Verify chain integrity
|
||||
const events = await logger.query({ order: "asc" });
|
||||
const result = await logger.verifyChainIntegrity(events);
|
||||
console.log(result.valid); // true
|
||||
|
||||
// GDPR export
|
||||
const gdprExport = await logger.exportGDPRData("user-123");
|
||||
console.log(gdprExport.summary.totalEvents);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [`src/core/audit-logger/audit-logger.md`](src/core/audit-logger/audit-logger.md) | Main logger class |
|
||||
| [`src/core/chain-integrity/chain-integrity.md`](src/core/chain-integrity/chain-integrity.md) | HMAC chain signing and verification |
|
||||
| [`src/adapters/memory-adapter/memory-adapter.md`](src/adapters/memory-adapter/memory-adapter.md) | In-memory storage adapter |
|
||||
| [`src/adapters/storage-adapter/storage-adapter.md`](src/adapters/storage-adapter/storage-adapter.md) | Storage adapter interface |
|
||||
| [`src/schemas/audit-logging.schemas.md`](src/schemas/audit-logging.schemas.md) | Zod validation schemas |
|
||||
| [`src/errors/audit-error.md`](src/errors/audit-error.md) | Error codes and AuditError class |
|
||||
| [`src/types/audit-logging.types.md`](src/types/audit-logging.types.md) | TypeScript type definitions |
|
||||
|
||||
## Event Taxonomy
|
||||
|
||||
| Category | Events |
|
||||
|----------|--------|
|
||||
| Authentication | `auth_login`, `auth_logout`, `auth_register`, `auth_password_change`, `auth_password_reset`, `auth_mfa_enable`, `auth_mfa_disable`, `auth_session_create`, `auth_session_revoke`, `auth_api_key_create`, `auth_api_key_revoke` |
|
||||
| Authorization | `authz_permission_check`, `authz_permission_grant`, `authz_permission_revoke`, `authz_role_assign`, `authz_role_unassign` |
|
||||
| Data Access | `data_create`, `data_read`, `data_update`, `data_delete`, `data_export`, `data_import` |
|
||||
| System | `system_config_change`, `system_security_alert`, `system_rate_limit`, `system_error` |
|
||||
| GDPR | `gdpr_data_export`, `gdpr_data_delete`, `gdpr_consent_change` |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
129
packages/observability/monitor/audit-logging/package.json
Normal file
129
packages/observability/monitor/audit-logging/package.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"name": "@suaveplan/audit-logging",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Tamper-proof, GDPR-compliant audit logging: cryptographic chain-integrity, pluggable storage, retention policy, GDPR export, and a rich event taxonomy",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"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"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/audit-error.d.ts",
|
||||
"default": "./dist/errors/audit-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/audit-error.d.ts",
|
||||
"default": "./dist/errors/audit-error.js"
|
||||
}
|
||||
},
|
||||
"./adapters/storage": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/storage/storage.d.ts",
|
||||
"default": "./dist/adapters/storage/storage.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/storage/storage.d.ts",
|
||||
"default": "./dist/adapters/storage/storage.js"
|
||||
}
|
||||
},
|
||||
"./adapters/memory/memory": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/memory/memory.d.ts",
|
||||
"default": "./dist/adapters/memory/memory.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/memory/memory.d.ts",
|
||||
"default": "./dist/adapters/memory/memory.js"
|
||||
}
|
||||
},
|
||||
"./core/audit-logger/audit-logger": {
|
||||
"import": {
|
||||
"types": "./dist/core/audit-logger/audit-logger.d.ts",
|
||||
"default": "./dist/core/audit-logger/audit-logger.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/audit-logger/audit-logger.d.ts",
|
||||
"default": "./dist/core/audit-logger/audit-logger.js"
|
||||
}
|
||||
},
|
||||
"./core/chain-integrity/chain-integrity": {
|
||||
"import": {
|
||||
"types": "./dist/core/chain-integrity/chain-integrity.d.ts",
|
||||
"default": "./dist/core/chain-integrity/chain-integrity.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/chain-integrity/chain-integrity.d.ts",
|
||||
"default": "./dist/core/chain-integrity/chain-integrity.js"
|
||||
}
|
||||
},
|
||||
"./schemas/audit-logging.schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/audit-logging.schemas.d.ts",
|
||||
"default": "./dist/schemas/audit-logging.schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/audit-logging.schemas.d.ts",
|
||||
"default": "./dist/schemas/audit-logging.schemas.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"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": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/crypto": "workspace:^",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/serialize": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MemoryAuditAdapter } from "./memory.js";
|
||||
@@ -0,0 +1,116 @@
|
||||
# memory-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides an in-memory implementation of `AuditStorageAdapter` backed by a `Map<string, AuditEvent>`. Designed for unit tests, integration tests, local development, and single-node scenarios where persistence across process restarts is not required. All filtering, sorting, and pagination are performed in-process without any external dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `AuditStorageAdapter` implementation — save, query, getLatestEvent, deleteExpired, archive, count
|
||||
- Rich query filtering: event types, userId, sessionId, severity (range-based), targetType, targetId, time range, tenantId
|
||||
- Stable ASC/DESC ordering by timestamp with offset/limit pagination
|
||||
- Separate archive store for events moved by `archive()`
|
||||
- `snapshot()` and `archivedSnapshot()` helpers for test assertions
|
||||
- `clear()` for test teardown
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
|
||||
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "test-secret",
|
||||
});
|
||||
|
||||
await logger.log({
|
||||
type: "auth_login",
|
||||
severity: "info",
|
||||
actor: { userId: "user-1" },
|
||||
hash: "",
|
||||
});
|
||||
|
||||
const events = adapter.snapshot();
|
||||
console.log(events.length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
|
||||
// Seed events
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await adapter.save({
|
||||
id: `evt-${i}`,
|
||||
type: i % 2 === 0 ? AuditEventType.AUTH_LOGIN : AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: `user-${i % 3}` },
|
||||
timestamp: new Date(Date.now() - i * 60_000),
|
||||
hash: "placeholder",
|
||||
});
|
||||
}
|
||||
|
||||
// Paginated query
|
||||
const page1 = await adapter.query({ limit: 5, offset: 0, order: "desc" });
|
||||
const page2 = await adapter.query({ limit: 5, offset: 5, order: "desc" });
|
||||
|
||||
// Count by user
|
||||
const userCount = await adapter.count({ userId: "user-0" });
|
||||
|
||||
// Archive old events
|
||||
const cutoff = new Date(Date.now() - 10 * 60_000);
|
||||
const archived = await adapter.archive(cutoff, "s3://audit-archive/2024");
|
||||
console.log(`Archived ${archived} events`);
|
||||
|
||||
// Delete remaining old events
|
||||
const deleted = await adapter.deleteExpired(cutoff);
|
||||
console.log(`Deleted ${deleted} expired events`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MemoryAuditAdapter`
|
||||
|
||||
**`save(event: AuditEvent): Promise<void>`**
|
||||
Stores the event by its `id`. Overwrites if `id` already exists.
|
||||
|
||||
**`query(options: AuditQueryOptions): Promise<AuditEvent[]>`**
|
||||
Filters, sorts, and paginates the in-memory event set. All filter fields are ANDed together.
|
||||
|
||||
- `severity` accepts a single value or array; filters to events matching any of the provided severities
|
||||
- `order` defaults to `"asc"`
|
||||
- `offset` defaults to 0; `limit` defaults to unlimited
|
||||
|
||||
**`getLatestEvent(tenantId?: string): Promise<AuditEvent | null>`**
|
||||
Returns the event with the most recent timestamp, optionally scoped to a tenant. Returns `null` if no events exist.
|
||||
|
||||
**`deleteExpired(before: Date): Promise<number>`**
|
||||
Removes all events where `timestamp < before`. Returns the count deleted.
|
||||
|
||||
**`archive(before: Date, location: string): Promise<number>`**
|
||||
Moves events where `timestamp < before` into the internal archive map (keyed by `location:id`). Returns the count archived.
|
||||
|
||||
**`count(options?: Partial<AuditQueryOptions>): Promise<number>`**
|
||||
Returns the total count of events matching the filter, or total size if no options provided.
|
||||
|
||||
**`snapshot(): AuditEvent[]`**
|
||||
Returns a copy of all currently stored (non-archived) events. For test assertions.
|
||||
|
||||
**`archivedSnapshot(): AuditEvent[]`**
|
||||
Returns all archived events. For test assertions.
|
||||
|
||||
**`clear(): void`**
|
||||
Empties both the main store and the archive. Use in `afterEach` test teardown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The severity filter applies range semantics: when multiple severities are provided it matches events whose severity order falls between the minimum and maximum provided ordinals. This is intentional — it allows callers to request `[WARNING, ERROR, CRITICAL]` without listing every intermediate level. Events are sorted using `Array.sort()` which is stable in V8 / Bun, so tie-breaking by insertion order is reliable. The internal `archived` map is keyed by `location:id` to allow the same event to be archived to multiple locations in tests without collision.
|
||||
@@ -0,0 +1,297 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
|
||||
import type { AuditEvent } from "../../types/audit-logging.types.js";
|
||||
import { MemoryAuditAdapter } from "./memory.js";
|
||||
|
||||
function makeEvent(overrides: Partial<AuditEvent> = {}): AuditEvent {
|
||||
return {
|
||||
id: `evt-${Math.random().toString(36).slice(2)}`,
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "user-1" },
|
||||
timestamp: new Date("2024-06-01T12:00:00Z"),
|
||||
hash: "fakehash",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MemoryAuditAdapter", () => {
|
||||
let adapter: MemoryAuditAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new MemoryAuditAdapter();
|
||||
});
|
||||
|
||||
describe("save and snapshot", () => {
|
||||
it("saves and retrieves an event", { timeout: 5000 }, async () => {
|
||||
const event = makeEvent({ id: "evt-1" });
|
||||
await adapter.save(event);
|
||||
expect(adapter.snapshot()).toEqual([event]);
|
||||
});
|
||||
|
||||
it("overwrites event with same id", { timeout: 5000 }, async () => {
|
||||
const e1 = makeEvent({ id: "evt-1", severity: AuditSeverity.INFO });
|
||||
const e2 = makeEvent({
|
||||
id: "evt-1",
|
||||
severity: AuditSeverity.ERROR,
|
||||
});
|
||||
await adapter.save(e1);
|
||||
await adapter.save(e2);
|
||||
expect(adapter.snapshot().length).toBe(1);
|
||||
expect(adapter.snapshot()[0]?.severity).toBe(AuditSeverity.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("query", () => {
|
||||
beforeEach(async () => {
|
||||
await adapter.save(
|
||||
makeEvent({
|
||||
id: "a",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
timestamp: new Date("2024-01-01"),
|
||||
})
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({
|
||||
id: "b",
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.WARNING,
|
||||
actor: { userId: "u2", sessionId: "s2" },
|
||||
timestamp: new Date("2024-02-01"),
|
||||
})
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({
|
||||
id: "c",
|
||||
type: AuditEventType.DATA_DELETE,
|
||||
severity: AuditSeverity.ERROR,
|
||||
actor: { userId: "u1" },
|
||||
target: { type: "doc", id: "doc-1" },
|
||||
timestamp: new Date("2024-03-01"),
|
||||
tenantId: "t1",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns all events when no filter", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({});
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it("filters by eventType", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
eventTypes: [AuditEventType.AUTH_LOGIN],
|
||||
});
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("filters by multiple eventTypes", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
eventTypes: [
|
||||
AuditEventType.AUTH_LOGIN,
|
||||
AuditEventType.DATA_READ,
|
||||
],
|
||||
});
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by userId", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ userId: "u1" });
|
||||
expect(result.map((e) => e.id).sort()).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("filters by sessionId", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ sessionId: "s2" });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("filters by single severity", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
severity: AuditSeverity.WARNING,
|
||||
});
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("filters by severity array", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
severity: [AuditSeverity.WARNING, AuditSeverity.ERROR],
|
||||
});
|
||||
expect(result.map((e) => e.id).sort()).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("filters by targetType", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ targetType: "doc" });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("c");
|
||||
});
|
||||
|
||||
it("filters by targetId", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ targetId: "doc-1" });
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
|
||||
it("filters by startTime", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
startTime: new Date("2024-02-01"),
|
||||
});
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by endTime", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({
|
||||
endTime: new Date("2024-02-01"),
|
||||
});
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by tenantId", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ tenantId: "t1" });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("c");
|
||||
});
|
||||
|
||||
it("orders asc by default", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({});
|
||||
expect(result[0]?.id).toBe("a");
|
||||
expect(result[2]?.id).toBe("c");
|
||||
});
|
||||
|
||||
it("orders desc", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ order: "desc" });
|
||||
expect(result[0]?.id).toBe("c");
|
||||
});
|
||||
|
||||
it("applies limit", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ limit: 2 });
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("applies offset", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ offset: 1 });
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("applies limit and offset", { timeout: 5000 }, async () => {
|
||||
const result = await adapter.query({ limit: 1, offset: 1 });
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLatestEvent", () => {
|
||||
it("returns null when empty", { timeout: 5000 }, async () => {
|
||||
expect(await adapter.getLatestEvent()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns most recent event", { timeout: 5000 }, async () => {
|
||||
await adapter.save(
|
||||
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
|
||||
);
|
||||
const latest = await adapter.getLatestEvent();
|
||||
expect(latest?.id).toBe("new");
|
||||
});
|
||||
|
||||
it("filters by tenantId", { timeout: 5000 }, async () => {
|
||||
await adapter.save(
|
||||
makeEvent({
|
||||
id: "t1e",
|
||||
timestamp: new Date("2024-12-31"),
|
||||
tenantId: "t1",
|
||||
})
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({
|
||||
id: "t2e",
|
||||
timestamp: new Date("2024-12-30"),
|
||||
tenantId: "t2",
|
||||
})
|
||||
);
|
||||
const latest = await adapter.getLatestEvent("t2");
|
||||
expect(latest?.id).toBe("t2e");
|
||||
});
|
||||
|
||||
it("returns null when no events for tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await adapter.save(makeEvent({ tenantId: "t1" }));
|
||||
expect(await adapter.getLatestEvent("t2")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteExpired", () => {
|
||||
it("deletes events before date", { timeout: 5000 }, async () => {
|
||||
await adapter.save(
|
||||
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
|
||||
);
|
||||
const deleted = await adapter.deleteExpired(new Date("2024-06-01"));
|
||||
expect(deleted).toBe(1);
|
||||
expect(adapter.snapshot().length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when nothing expired", { timeout: 5000 }, async () => {
|
||||
await adapter.save(
|
||||
makeEvent({ timestamp: new Date("2024-12-31") })
|
||||
);
|
||||
expect(await adapter.deleteExpired(new Date("2024-01-01"))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive", () => {
|
||||
it("moves events to archive store", { timeout: 5000 }, async () => {
|
||||
await adapter.save(
|
||||
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
|
||||
);
|
||||
await adapter.save(
|
||||
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
|
||||
);
|
||||
const count = await adapter.archive(
|
||||
new Date("2024-06-01"),
|
||||
"s3://bucket/archive"
|
||||
);
|
||||
expect(count).toBe(1);
|
||||
expect(adapter.snapshot().length).toBe(1);
|
||||
expect(adapter.archivedSnapshot().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("count", () => {
|
||||
it("counts all events", { timeout: 5000 }, async () => {
|
||||
await adapter.save(makeEvent());
|
||||
await adapter.save(makeEvent());
|
||||
expect(await adapter.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("counts with filter", { timeout: 5000 }, async () => {
|
||||
await adapter.save(makeEvent({ type: AuditEventType.AUTH_LOGIN }));
|
||||
await adapter.save(makeEvent({ type: AuditEventType.DATA_READ }));
|
||||
expect(
|
||||
await adapter.count({ eventTypes: [AuditEventType.AUTH_LOGIN] })
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for empty store", { timeout: 5000 }, async () => {
|
||||
expect(await adapter.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("clears all events", { timeout: 5000 }, async () => {
|
||||
await adapter.save(makeEvent());
|
||||
await adapter.archive(new Date("2099-01-01"), "loc");
|
||||
adapter.clear();
|
||||
expect(adapter.snapshot().length).toBe(0);
|
||||
expect(adapter.archivedSnapshot().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { AuditSeverity } from "../../constants/index.js";
|
||||
import type {
|
||||
AuditEvent,
|
||||
AuditQueryOptions,
|
||||
AuditStorageAdapter,
|
||||
} from "../../types/audit-logging.types.js";
|
||||
|
||||
const SEVERITY_ORDER: Record<AuditSeverity, number> = {
|
||||
[AuditSeverity.DEBUG]: 0,
|
||||
[AuditSeverity.INFO]: 1,
|
||||
[AuditSeverity.WARNING]: 2,
|
||||
[AuditSeverity.ERROR]: 3,
|
||||
[AuditSeverity.CRITICAL]: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory implementation of `AuditStorageAdapter`.
|
||||
*
|
||||
* Events are stored in a `Map<string, AuditEvent>` keyed by event ID.
|
||||
* Suitable for testing, development, and single-node deployments that do
|
||||
* not require persistence across restarts.
|
||||
*/
|
||||
export class MemoryAuditAdapter implements AuditStorageAdapter {
|
||||
protected readonly events: Map<string, AuditEvent> = new Map();
|
||||
protected readonly archived: Map<string, AuditEvent> = new Map();
|
||||
|
||||
async save(event: AuditEvent): Promise<void> {
|
||||
this.events.set(event.id, event);
|
||||
}
|
||||
|
||||
async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
|
||||
let results = Array.from(this.events.values());
|
||||
|
||||
results = this.applyFilters(results, options);
|
||||
|
||||
const order = options.order ?? "asc";
|
||||
results.sort((a, b) => {
|
||||
const diff = a.timestamp.getTime() - b.timestamp.getTime();
|
||||
return order === "asc" ? diff : -diff;
|
||||
});
|
||||
|
||||
const offset = options.offset ?? 0;
|
||||
results = results.slice(offset);
|
||||
|
||||
if (options.limit !== undefined) {
|
||||
results = results.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getLatestEvent(tenantId?: string): Promise<AuditEvent | null> {
|
||||
let results = Array.from(this.events.values());
|
||||
|
||||
if (tenantId !== undefined) {
|
||||
results = results.filter((e) => e.tenantId === tenantId);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
// results.length > 0 is guaranteed by the check above
|
||||
return results[0] as AuditEvent;
|
||||
}
|
||||
|
||||
async deleteExpired(before: Date): Promise<number> {
|
||||
let count = 0;
|
||||
for (const [id, event] of this.events) {
|
||||
if (event.timestamp < before) {
|
||||
this.events.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async archive(before: Date, location: string): Promise<number> {
|
||||
let count = 0;
|
||||
for (const [id, event] of this.events) {
|
||||
if (event.timestamp < before) {
|
||||
this.archived.set(`${location}:${id}`, event);
|
||||
this.events.delete(id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async count(options?: Partial<AuditQueryOptions>): Promise<number> {
|
||||
if (options === undefined) {
|
||||
return this.events.size;
|
||||
}
|
||||
const all = Array.from(this.events.values());
|
||||
return this.applyFilters(all, options).length;
|
||||
}
|
||||
|
||||
/** Returns a snapshot of all stored events for testing purposes. */
|
||||
snapshot(): AuditEvent[] {
|
||||
return Array.from(this.events.values());
|
||||
}
|
||||
|
||||
/** Clears all stored events. */
|
||||
clear(): void {
|
||||
this.events.clear();
|
||||
this.archived.clear();
|
||||
}
|
||||
|
||||
/** Returns archived events for inspection in tests. */
|
||||
archivedSnapshot(): AuditEvent[] {
|
||||
return Array.from(this.archived.values());
|
||||
}
|
||||
|
||||
protected applyFilters(
|
||||
events: AuditEvent[],
|
||||
options: Partial<AuditQueryOptions>
|
||||
): AuditEvent[] {
|
||||
let results = events;
|
||||
|
||||
if (options.eventTypes !== undefined && options.eventTypes.length > 0) {
|
||||
const types = new Set(options.eventTypes as string[]);
|
||||
results = results.filter((e) => types.has(e.type as string));
|
||||
}
|
||||
|
||||
if (options.userId !== undefined) {
|
||||
const uid = options.userId;
|
||||
results = results.filter((e) => e.actor.userId === uid);
|
||||
}
|
||||
|
||||
if (options.sessionId !== undefined) {
|
||||
const sid = options.sessionId;
|
||||
results = results.filter((e) => e.actor.sessionId === sid);
|
||||
}
|
||||
|
||||
if (options.severity !== undefined) {
|
||||
const severities = Array.isArray(options.severity)
|
||||
? options.severity
|
||||
: [options.severity];
|
||||
const orders = severities.map((s) => SEVERITY_ORDER[s] as number);
|
||||
const minOrder = Math.min(...orders);
|
||||
const maxOrder = Math.max(...orders);
|
||||
results = results.filter((e) => {
|
||||
const ord = SEVERITY_ORDER[
|
||||
e.severity as AuditSeverity
|
||||
] as number;
|
||||
return ord >= minOrder && ord <= maxOrder;
|
||||
});
|
||||
}
|
||||
|
||||
if (options.targetType !== undefined) {
|
||||
const tt = options.targetType;
|
||||
results = results.filter((e) => e.target?.type === tt);
|
||||
}
|
||||
|
||||
if (options.targetId !== undefined) {
|
||||
const tid = options.targetId;
|
||||
results = results.filter((e) => e.target?.id === tid);
|
||||
}
|
||||
|
||||
if (options.startTime !== undefined) {
|
||||
const st = options.startTime;
|
||||
results = results.filter((e) => e.timestamp >= st);
|
||||
}
|
||||
|
||||
if (options.endTime !== undefined) {
|
||||
const et = options.endTime;
|
||||
results = results.filter((e) => e.timestamp <= et);
|
||||
}
|
||||
|
||||
if (options.tenantId !== undefined) {
|
||||
const tid = options.tenantId;
|
||||
results = results.filter((e) => e.tenantId === tid);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type AuditStorageAdapter,
|
||||
BaseAuditStorageAdapter,
|
||||
} from "./storage.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
# storage-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `AuditStorageAdapter` interface contract that all storage backends must implement. This adapter pattern decouples the `AuditLogger` from any specific persistence technology — in-memory, SQLite, SurrealDB, PostgreSQL, or S3 — allowing implementations to be swapped without changing logger code.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuditStorageAdapter` interface with six methods covering the full CRUD + lifecycle surface
|
||||
- `BaseAuditStorageAdapter` abstract class with typed abstract method signatures for concrete adapters
|
||||
- Supports pagination, time-range filtering, tenant scoping, and soft-archive semantics
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { AuditStorageAdapter } from "@suaveplan/audit-logging/adapters/storage";
|
||||
|
||||
class MyAdapter implements AuditStorageAdapter {
|
||||
async save(event) { /* ... */ }
|
||||
async query(options) { return []; }
|
||||
async getLatestEvent(tenantId?) { return null; }
|
||||
async deleteExpired(before) { return 0; }
|
||||
async archive(before, location) { return 0; }
|
||||
async count(options?) { return 0; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BaseAuditStorageAdapter } from "@suaveplan/audit-logging/adapters/storage";
|
||||
import type { AuditEvent, AuditQueryOptions } from "@suaveplan/audit-logging";
|
||||
|
||||
class SqliteAuditAdapter extends BaseAuditStorageAdapter {
|
||||
constructor(private readonly db: DatabaseAdapter) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(event: AuditEvent): Promise<void> {
|
||||
await this.db.query("INSERT INTO audit_events VALUES (?)", [JSON.stringify(event)]);
|
||||
}
|
||||
|
||||
async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
|
||||
// Build WHERE clause from options...
|
||||
return [];
|
||||
}
|
||||
|
||||
async getLatestEvent(tenantId?: string): Promise<AuditEvent | null> {
|
||||
// SELECT ... ORDER BY timestamp DESC LIMIT 1
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteExpired(before: Date): Promise<number> {
|
||||
const result = await this.db.query("DELETE FROM audit_events WHERE timestamp < ?", [before]);
|
||||
return result.rowsAffected ?? 0;
|
||||
}
|
||||
|
||||
async archive(before: Date, location: string): Promise<number> {
|
||||
// Copy to archive, then mark deleted
|
||||
return 0;
|
||||
}
|
||||
|
||||
async count(options?: Partial<AuditQueryOptions>): Promise<number> {
|
||||
const result = await this.db.query("SELECT COUNT(*) FROM audit_events");
|
||||
return Number(result.rows[0][0]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuditStorageAdapter` (interface)
|
||||
|
||||
**`save(event: AuditEvent): Promise<void>`**
|
||||
Persist a single audit event. Throws `AuditError` with `STORAGE_ERROR` on failure.
|
||||
|
||||
**`query(options: AuditQueryOptions): Promise<AuditEvent[]>`**
|
||||
Filter and paginate events. Applies all provided filters; returns an empty array when no events match.
|
||||
|
||||
**`getLatestEvent(tenantId?: string): Promise<AuditEvent | null>`**
|
||||
Returns the most recent event (by timestamp) for the given tenant, or globally if no tenant is specified. Used by `AuditLogger` to link HMAC chains.
|
||||
|
||||
**`deleteExpired(before: Date): Promise<number>`**
|
||||
Hard-deletes all events with `timestamp < before`. Returns the count of deleted records.
|
||||
|
||||
**`archive(before: Date, location: string): Promise<number>`**
|
||||
Copies events with `timestamp < before` to `location`, then marks them. Returns the count of archived records.
|
||||
|
||||
**`count(options?: Partial<AuditQueryOptions>): Promise<number>`**
|
||||
Returns the total number of events matching the optional filter criteria.
|
||||
|
||||
### `BaseAuditStorageAdapter` (abstract class)
|
||||
|
||||
Abstract base that implements `AuditStorageAdapter` with abstract method signatures. Extend this class when building new adapters to get compile-time enforcement that all required methods are implemented.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface is intentionally minimal — it does not prescribe transaction semantics, connection pooling, or retry logic, which are concerns of specific adapter implementations. The `archive` method is a two-phase operation; the exact archival format (JSON Lines, Parquet, CSV) is left to the implementation. The `getLatestEvent` method is called once per `log()` invocation when chain integrity is enabled, so implementations should ensure it is fast (indexed by timestamp).
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
AuditEvent,
|
||||
AuditQueryOptions,
|
||||
AuditStorageAdapter,
|
||||
} from "../../types/audit-logging.types.js";
|
||||
|
||||
export type { AuditStorageAdapter };
|
||||
|
||||
/**
|
||||
* Base abstract class that provides shared validation helpers for
|
||||
* `AuditStorageAdapter` implementations. Concrete adapters extend this
|
||||
* and implement the abstract storage methods.
|
||||
*/
|
||||
export abstract class BaseAuditStorageAdapter implements AuditStorageAdapter {
|
||||
abstract save(event: AuditEvent): Promise<void>;
|
||||
abstract query(options: AuditQueryOptions): Promise<AuditEvent[]>;
|
||||
abstract getLatestEvent(tenantId?: string): Promise<AuditEvent | null>;
|
||||
abstract deleteExpired(before: Date): Promise<number>;
|
||||
abstract archive(before: Date, location: string): Promise<number>;
|
||||
abstract count(options?: Partial<AuditQueryOptions>): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Audit event type taxonomy covering auth, authorization, data access, system, and GDPR events. */
|
||||
export enum AuditEventType {
|
||||
// Authentication
|
||||
AUTH_LOGIN = "auth_login",
|
||||
AUTH_LOGOUT = "auth_logout",
|
||||
AUTH_REGISTER = "auth_register",
|
||||
AUTH_PASSWORD_CHANGE = "auth_password_change",
|
||||
AUTH_PASSWORD_RESET = "auth_password_reset",
|
||||
AUTH_MFA_ENABLE = "auth_mfa_enable",
|
||||
AUTH_MFA_DISABLE = "auth_mfa_disable",
|
||||
AUTH_SESSION_CREATE = "auth_session_create",
|
||||
AUTH_SESSION_REVOKE = "auth_session_revoke",
|
||||
AUTH_API_KEY_CREATE = "auth_api_key_create",
|
||||
AUTH_API_KEY_REVOKE = "auth_api_key_revoke",
|
||||
// Authorization
|
||||
AUTHZ_PERMISSION_CHECK = "authz_permission_check",
|
||||
AUTHZ_PERMISSION_GRANT = "authz_permission_grant",
|
||||
AUTHZ_PERMISSION_REVOKE = "authz_permission_revoke",
|
||||
AUTHZ_ROLE_ASSIGN = "authz_role_assign",
|
||||
AUTHZ_ROLE_UNASSIGN = "authz_role_unassign",
|
||||
// Data access
|
||||
DATA_CREATE = "data_create",
|
||||
DATA_READ = "data_read",
|
||||
DATA_UPDATE = "data_update",
|
||||
DATA_DELETE = "data_delete",
|
||||
DATA_EXPORT = "data_export",
|
||||
DATA_IMPORT = "data_import",
|
||||
// System
|
||||
SYSTEM_CONFIG_CHANGE = "system_config_change",
|
||||
SYSTEM_SECURITY_ALERT = "system_security_alert",
|
||||
SYSTEM_RATE_LIMIT = "system_rate_limit",
|
||||
SYSTEM_ERROR = "system_error",
|
||||
// GDPR
|
||||
GDPR_DATA_EXPORT = "gdpr_data_export",
|
||||
GDPR_DATA_DELETE = "gdpr_data_delete",
|
||||
GDPR_CONSENT_CHANGE = "gdpr_consent_change",
|
||||
}
|
||||
|
||||
/** Severity levels for audit events. */
|
||||
export enum AuditSeverity {
|
||||
DEBUG = "debug",
|
||||
INFO = "info",
|
||||
WARNING = "warning",
|
||||
ERROR = "error",
|
||||
CRITICAL = "critical",
|
||||
}
|
||||
|
||||
/** Default table name for audit events in storage adapters. */
|
||||
export const DEFAULT_AUDIT_TABLE = "audit_events";
|
||||
/** Default number of days to retain audit events. */
|
||||
export const DEFAULT_RETENTION_DAYS = 365;
|
||||
/** OTel metric name for logged audit events. */
|
||||
export const OTEL_EVENTS_LOGGED = "audit.events_logged_total";
|
||||
/** Bus event emitted after each successful audit event log. */
|
||||
export const BUS_AUDIT_EVENT_LOGGED = "audit:event:logged";
|
||||
@@ -0,0 +1,127 @@
|
||||
# audit-logger
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `AuditLogger` class is the primary entry point for the `@suaveplan/audit-logging` package. It orchestrates event creation (ID generation, timestamping, HMAC signing), storage persistence, GDPR data export, and retention policy enforcement. All dependencies — storage adapter, clock, ID generator, logger, and event bus — are injected through the constructor, enabling full testability without mocks.
|
||||
|
||||
## Features
|
||||
|
||||
- `log()` — generates ID and timestamp, computes HMAC hash (with optional chain linking), saves event, emits OTel counter and bus event
|
||||
- `query()` — delegates to the storage adapter with full filter support
|
||||
- `verifyChainIntegrity()` — delegates to `verifyChain()` in chain-integrity module
|
||||
- `exportGDPRData()` — collects all events for a user ID, builds summary statistics, respects AbortSignal
|
||||
- `applyRetentionPolicy()` — archives then hard-deletes events beyond the retention window
|
||||
- Injectable `Clock` and `IdGenerator` from `@suaveplan/types`
|
||||
- OTel counter `audit.events_logged_total` with `event_type` and `severity` labels
|
||||
- Bus event `audit:event:logged` emitted after each successful log
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
|
||||
const logger = new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
});
|
||||
|
||||
const event = await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "user-123", ipAddress: "203.0.113.1" },
|
||||
target: { type: "session", id: "sess-456" },
|
||||
});
|
||||
|
||||
console.log(event.id); // generated UUID
|
||||
console.log(event.hash); // hex HMAC-SHA256
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
let seq = 0;
|
||||
|
||||
const logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "my-secret",
|
||||
enableChainIntegrity: true,
|
||||
retentionPolicy: {
|
||||
retentionDays: 90,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://my-bucket/audit",
|
||||
},
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => `evt-${++seq}`,
|
||||
});
|
||||
|
||||
// Log a chain of events
|
||||
await logger.log({ type: AuditEventType.AUTH_LOGIN, severity: AuditSeverity.INFO, actor: { userId: "u1" } });
|
||||
await logger.log({ type: AuditEventType.DATA_READ, severity: AuditSeverity.INFO, actor: { userId: "u1" }, target: { type: "report", id: "r-1" } });
|
||||
|
||||
// Verify chain
|
||||
const events = await logger.query({ order: "asc" });
|
||||
const result = await logger.verifyChainIntegrity(events);
|
||||
console.log(result.valid); // true
|
||||
|
||||
// GDPR export
|
||||
const gdprExport = await logger.exportGDPRData("u1");
|
||||
console.log(gdprExport.summary.totalEvents); // 2
|
||||
|
||||
// Apply retention
|
||||
const retention = await logger.applyRetentionPolicy();
|
||||
console.log(retention.eventsDeleted, retention.eventsArchived);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AuditLogger(options: AuditLoggerOptions)`
|
||||
|
||||
Throws `AuditError` with `SECRET_KEY_MISSING` if `options.secretKey` is empty.
|
||||
|
||||
### `log(event): Promise<AuditEvent>`
|
||||
|
||||
Input type: `Omit<AuditEvent, 'id' | 'timestamp' | 'hash' | 'previousHash'>`.
|
||||
|
||||
- Generates `id` via `idGenerator()`
|
||||
- Sets `timestamp` via `new Date(clock())`
|
||||
- If `enableChainIntegrity`: fetches latest event hash as `previousHash`
|
||||
- Computes `hash = computeEventHash(event, secretKey, previousHash)`
|
||||
- Saves to storage; throws `AuditError(STORAGE_ERROR)` on failure
|
||||
- Increments OTel counter `audit.events_logged_total`
|
||||
- Emits `audit:event:logged` on bus
|
||||
|
||||
### `query(options): Promise<AuditEvent[]>`
|
||||
|
||||
Delegates to `storage.query(options)`. Throws `AuditError(STORAGE_ERROR)` on storage failure.
|
||||
|
||||
### `verifyChainIntegrity(events): Promise<ChainVerificationResult>`
|
||||
|
||||
Events must be in ascending chronological order. Delegates to `verifyChain`.
|
||||
|
||||
### `exportGDPRData(userId, signal?): Promise<GDPRDataExport>`
|
||||
|
||||
- Queries all events with `{ userId }`
|
||||
- Builds `eventTypes` frequency map and `dateRange`
|
||||
- Returns `GDPRDataExport` with summary
|
||||
- Throws `AuditError(EXPORT_FAILED)` on aborted signal or storage failure
|
||||
|
||||
### `applyRetentionPolicy(signal?): Promise<RetentionResult>`
|
||||
|
||||
- Requires `retentionPolicy` option to be set (returns empty result otherwise)
|
||||
- Archives events older than `retentionDays` if `archiveBeforeDelete && archiveLocation`
|
||||
- Hard-deletes expired events via `storage.deleteExpired(cutoff)`
|
||||
- Non-fatal errors are collected in `result.errors`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The counter and meter are created once at module scope to avoid per-instance SDK lookups and respect OTel's singleton meter provider model. The `bus.emit` call is fire-and-forget (no await) to keep the hot path synchronous. The `previousHash` lookup uses tenant scoping when `event.tenantId` is set, so multi-tenant deployments maintain separate chains per tenant. All optional properties on `AuditEvent` are included via spread with conditional key inclusion to satisfy `exactOptionalPropertyTypes`.
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryAuditAdapter } from "../../adapters/memory/memory.js";
|
||||
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
|
||||
import { AUDIT_CODES, AuditError } from "../../errors/audit-error.js";
|
||||
import type { AuditEvent } from "../../types/audit-logging.types.js";
|
||||
import { AuditLogger } from "./audit-logger.js";
|
||||
|
||||
function makeLogger(
|
||||
overrides: Partial<ConstructorParameters<typeof AuditLogger>[0]> = {}
|
||||
) {
|
||||
return new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "test-secret-key",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function baseEvent(): Omit<
|
||||
AuditEvent,
|
||||
"id" | "timestamp" | "hash" | "previousHash"
|
||||
> {
|
||||
return {
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "user-1" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("AuditLogger", () => {
|
||||
describe("constructor", () => {
|
||||
it("throws SECRET_KEY_MISSING when secretKey is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => makeLogger({ secretKey: "" })).toThrow(AuditError);
|
||||
try {
|
||||
makeLogger({ secretKey: "" });
|
||||
} catch (err) {
|
||||
expect(err instanceof AuditError && err.code).toBe(
|
||||
AUDIT_CODES.SECRET_KEY_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("log", () => {
|
||||
it("creates an event with id, timestamp, and hash", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const event = await logger.log(baseEvent());
|
||||
expect(event.id).toBeDefined();
|
||||
expect(event.timestamp).toBeInstanceOf(Date);
|
||||
expect(event.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("uses injected idGenerator", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger({ idGenerator: () => "fixed-id" });
|
||||
const event = await logger.log(baseEvent());
|
||||
expect(event.id).toBe("fixed-id");
|
||||
});
|
||||
|
||||
it("uses injected clock", { timeout: 5000 }, async () => {
|
||||
const ts = new Date("2024-01-01T00:00:00Z").getTime();
|
||||
const logger = makeLogger({ clock: () => ts });
|
||||
const event = await logger.log(baseEvent());
|
||||
expect(event.timestamp.getTime()).toBe(ts);
|
||||
});
|
||||
|
||||
it("logs with optional fields", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
const event = await logger.log({
|
||||
...baseEvent(),
|
||||
tenantId: "t1",
|
||||
correlationId: "corr-1",
|
||||
target: { type: "doc", id: "d1" },
|
||||
details: { key: "value" },
|
||||
});
|
||||
expect(event.tenantId).toBe("t1");
|
||||
expect(event.correlationId).toBe("corr-1");
|
||||
});
|
||||
|
||||
it("chains events when enableChainIntegrity is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: true });
|
||||
const e1 = await logger.log(baseEvent());
|
||||
const e2 = await logger.log(baseEvent());
|
||||
expect(e2.previousHash).toBe(e1.hash);
|
||||
});
|
||||
|
||||
it("no previousHash when first event with chain integrity", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: true });
|
||||
const event = await logger.log(baseEvent());
|
||||
expect(event.previousHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no previousHash without chain integrity", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: false });
|
||||
const event = await logger.log(baseEvent());
|
||||
expect(event.previousHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits bus event when bus is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const emitted: unknown[] = [];
|
||||
const bus = {
|
||||
on: () => () => {},
|
||||
emit: (_: string, payload: unknown) => {
|
||||
emitted.push(payload);
|
||||
},
|
||||
};
|
||||
const logger = makeLogger({ bus });
|
||||
await logger.log(baseEvent());
|
||||
expect(emitted.length).toBe(1);
|
||||
});
|
||||
|
||||
it("calls logger.debug when logger is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const messages: string[] = [];
|
||||
const testLogger = {
|
||||
debug: (msg: string) => {
|
||||
messages.push(msg);
|
||||
},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
const logger = makeLogger({ logger: testLogger });
|
||||
await logger.log(baseEvent());
|
||||
expect(messages.some((m) => m.includes("logged"))).toBe(true);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR when save fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {
|
||||
throw new Error("DB error");
|
||||
},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR when save fails (non-Error)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {
|
||||
throw "non-error save failure";
|
||||
},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR when getLatestEvent fails with chain integrity (non-Error)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => {
|
||||
throw "non-error";
|
||||
},
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR when getLatestEvent fails with chain integrity", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => {
|
||||
throw new Error("DB error");
|
||||
},
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("query", () => {
|
||||
it("returns empty array initially", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
expect(await logger.query({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns logged events", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
await logger.log(baseEvent());
|
||||
await logger.log(baseEvent());
|
||||
expect((await logger.query({})).length).toBe(2);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR on query failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => {
|
||||
throw new Error("DB error");
|
||||
},
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.query({})).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws STORAGE_ERROR on non-Error query failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => {
|
||||
throw "non-error query failure";
|
||||
},
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.query({})).rejects.toThrow(AuditError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyChainIntegrity", () => {
|
||||
it("returns valid for empty list", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
expect((await logger.verifyChainIntegrity([])).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns valid for correctly chained events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: true });
|
||||
await logger.log(baseEvent());
|
||||
await logger.log(baseEvent());
|
||||
const events = await logger.query({ order: "asc" });
|
||||
const result = await logger.verifyChainIntegrity(events);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("detects tampered event", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: true });
|
||||
await logger.log(baseEvent());
|
||||
const events = await logger.query({});
|
||||
const tampered: AuditEvent[] = events.map((e) => ({
|
||||
...e,
|
||||
actor: { userId: "attacker" },
|
||||
}));
|
||||
const result = await logger.verifyChainIntegrity(tampered);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportGDPRData", () => {
|
||||
it("returns empty export for unknown user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const result = await logger.exportGDPRData("unknown-user");
|
||||
expect(result.auditEvents.length).toBe(0);
|
||||
expect(result.summary.totalEvents).toBe(0);
|
||||
});
|
||||
|
||||
it("exports events for user", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
await logger.log({ ...baseEvent(), actor: { userId: "u1" } });
|
||||
await logger.log({
|
||||
...baseEvent(),
|
||||
actor: { userId: "u1" },
|
||||
type: AuditEventType.DATA_READ,
|
||||
});
|
||||
await logger.log({ ...baseEvent(), actor: { userId: "u2" } });
|
||||
|
||||
const result = await logger.exportGDPRData("u1");
|
||||
expect(result.userId).toBe("u1");
|
||||
expect(result.auditEvents.length).toBe(2);
|
||||
expect(result.summary.totalEvents).toBe(2);
|
||||
});
|
||||
|
||||
it("builds eventTypes summary", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger();
|
||||
await logger.log({
|
||||
...baseEvent(),
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
});
|
||||
await logger.log({
|
||||
...baseEvent(),
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
});
|
||||
await logger.log({
|
||||
...baseEvent(),
|
||||
type: AuditEventType.DATA_READ,
|
||||
});
|
||||
|
||||
const result = await logger.exportGDPRData("user-1");
|
||||
expect(result.summary.eventTypes[AuditEventType.AUTH_LOGIN]).toBe(
|
||||
2
|
||||
);
|
||||
expect(result.summary.eventTypes[AuditEventType.DATA_READ]).toBe(1);
|
||||
});
|
||||
|
||||
it("throws EXPORT_FAILED when aborted before query", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const signal = AbortSignal.abort();
|
||||
await expect(
|
||||
logger.exportGDPRData("user-1", signal)
|
||||
).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws EXPORT_FAILED when aborted after query", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const abortingStorage = {
|
||||
save: async () => {},
|
||||
query: async () => {
|
||||
controller.abort();
|
||||
return [];
|
||||
},
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: abortingStorage });
|
||||
await expect(
|
||||
logger.exportGDPRData("user-1", controller.signal)
|
||||
).rejects.toThrow(AuditError);
|
||||
});
|
||||
|
||||
it("throws EXPORT_FAILED on storage error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => {
|
||||
throw new Error("DB error");
|
||||
},
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.exportGDPRData("u1")).rejects.toThrow(
|
||||
AuditError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws EXPORT_FAILED on non-Error storage failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => {
|
||||
throw "non-error export failure";
|
||||
},
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({ storage: badStorage });
|
||||
await expect(logger.exportGDPRData("u1")).rejects.toThrow(
|
||||
AuditError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRetentionPolicy", () => {
|
||||
it("returns empty result with no policy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger();
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.eventsDeleted).toBe(0);
|
||||
expect(result.eventsArchived).toBe(0);
|
||||
});
|
||||
|
||||
it("deletes expired events", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
const logger = makeLogger({
|
||||
storage: adapter,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: false,
|
||||
},
|
||||
clock: () => new Date("2024-12-31").getTime(),
|
||||
});
|
||||
// Manually save an old event
|
||||
await adapter.save({
|
||||
id: "old-1",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: {},
|
||||
timestamp: new Date("2024-01-01"),
|
||||
hash: "h",
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.eventsDeleted).toBe(1);
|
||||
});
|
||||
|
||||
it("archives before delete when configured", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
const logger = makeLogger({
|
||||
storage: adapter,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://bucket/archive",
|
||||
},
|
||||
clock: () => new Date("2024-12-31").getTime(),
|
||||
});
|
||||
await adapter.save({
|
||||
id: "old-1",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: {},
|
||||
timestamp: new Date("2024-01-01"),
|
||||
hash: "h",
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.eventsArchived).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty when aborted before start", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const logger = makeLogger({
|
||||
retentionPolicy: {
|
||||
retentionDays: 30,
|
||||
archiveBeforeDelete: false,
|
||||
},
|
||||
});
|
||||
const signal = AbortSignal.abort();
|
||||
const result = await logger.applyRetentionPolicy(signal);
|
||||
expect(result.eventsDeleted).toBe(0);
|
||||
});
|
||||
|
||||
it("collects errors from archive failure without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => {
|
||||
throw new Error("archive failed");
|
||||
},
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://bucket",
|
||||
},
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns early when aborted after archive", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const archiveStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => {
|
||||
controller.abort();
|
||||
return 0;
|
||||
},
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: archiveStorage,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://bucket",
|
||||
},
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy(controller.signal);
|
||||
expect(result.eventsDeleted).toBe(0);
|
||||
});
|
||||
|
||||
it("collects errors from delete failure without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => {
|
||||
throw new Error("delete failed");
|
||||
},
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: false,
|
||||
},
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles non-Error archive throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => {
|
||||
throw "non-error archive";
|
||||
},
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://bucket",
|
||||
},
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.errors[0]).toContain("non-error archive");
|
||||
});
|
||||
|
||||
it("handles non-Error delete throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const badStorage = {
|
||||
save: async () => {},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => {
|
||||
throw "non-error delete";
|
||||
},
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = makeLogger({
|
||||
storage: badStorage,
|
||||
retentionPolicy: {
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: false,
|
||||
},
|
||||
});
|
||||
const result = await logger.applyRetentionPolicy();
|
||||
expect(result.errors[0]).toContain("non-error delete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chain integrity with tenantId", () => {
|
||||
it("links chain per tenant", { timeout: 5000 }, async () => {
|
||||
const logger = makeLogger({ enableChainIntegrity: true });
|
||||
const e1 = await logger.log({ ...baseEvent(), tenantId: "t1" });
|
||||
const e2 = await logger.log({ ...baseEvent(), tenantId: "t1" });
|
||||
expect(e2.previousHash).toBe(e1.hash);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import {
|
||||
BUS_AUDIT_EVENT_LOGGED,
|
||||
OTEL_EVENTS_LOGGED,
|
||||
} from "../../constants/index.js";
|
||||
import { AUDIT_CODES, AuditError } from "../../errors/audit-error.js";
|
||||
import type {
|
||||
AuditEvent,
|
||||
AuditLoggerOptions,
|
||||
AuditQueryOptions,
|
||||
ChainVerificationResult,
|
||||
GDPRDataExport,
|
||||
RetentionResult,
|
||||
} from "../../types/audit-logging.types.js";
|
||||
import {
|
||||
computeEventHash,
|
||||
verifyChain,
|
||||
} from "../chain-integrity/chain-integrity.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/audit-logging");
|
||||
const eventsLoggedCounter = meter.createCounter(OTEL_EVENTS_LOGGED);
|
||||
|
||||
const DEFAULT_CLOCK: Clock = () => Date.now();
|
||||
const DEFAULT_ID_GENERATOR: IdGenerator = () => crypto.randomUUID();
|
||||
|
||||
/**
|
||||
* Enterprise-grade audit logger with cryptographic chain integrity,
|
||||
* GDPR export, and configurable retention policy.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const logger = new AuditLogger({
|
||||
* storage: new MemoryAuditAdapter(),
|
||||
* secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
* enableChainIntegrity: true,
|
||||
* });
|
||||
* await logger.log({ type: AuditEventType.AUTH_LOGIN, severity: AuditSeverity.INFO, actor: { userId: "u1" } });
|
||||
* ```
|
||||
*/
|
||||
export class AuditLogger {
|
||||
protected readonly storage: AuditLoggerOptions["storage"];
|
||||
protected readonly secretKey: string;
|
||||
protected readonly enableChainIntegrity: boolean;
|
||||
protected readonly clock: Clock;
|
||||
protected readonly idGenerator: IdGenerator;
|
||||
protected readonly logger: AuditLoggerOptions["logger"];
|
||||
protected readonly bus: AuditLoggerOptions["bus"];
|
||||
protected readonly retentionPolicy: AuditLoggerOptions["retentionPolicy"];
|
||||
|
||||
public constructor(options: AuditLoggerOptions) {
|
||||
if (!options.secretKey) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.SECRET_KEY_MISSING,
|
||||
"audit.secret_key.missing"
|
||||
);
|
||||
}
|
||||
this.storage = options.storage;
|
||||
this.secretKey = options.secretKey;
|
||||
this.enableChainIntegrity = options.enableChainIntegrity ?? false;
|
||||
this.clock = options.clock ?? DEFAULT_CLOCK;
|
||||
this.idGenerator = options.idGenerator ?? DEFAULT_ID_GENERATOR;
|
||||
this.logger = options.logger;
|
||||
this.bus = options.bus;
|
||||
this.retentionPolicy = options.retentionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an audit event, computing its ID, timestamp, and HMAC hash.
|
||||
* If chain integrity is enabled, fetches the latest event hash and
|
||||
* links it as `previousHash`.
|
||||
*/
|
||||
public async log(
|
||||
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
|
||||
): Promise<AuditEvent> {
|
||||
const id = this.idGenerator();
|
||||
const timestamp = new Date(this.clock());
|
||||
|
||||
let previousHash: string | undefined;
|
||||
if (this.enableChainIntegrity) {
|
||||
try {
|
||||
const latest = await this.storage.getLatestEvent(
|
||||
event.tenantId
|
||||
);
|
||||
if (latest !== null) {
|
||||
previousHash = latest.hash;
|
||||
}
|
||||
} catch (err) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.get_latest.failed",
|
||||
{ ...(err instanceof Error ? { cause: err } : {}) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eventBase = { id, timestamp, ...event };
|
||||
const hash = computeEventHash(eventBase, this.secretKey, previousHash);
|
||||
|
||||
const auditEvent: AuditEvent = {
|
||||
...eventBase,
|
||||
hash,
|
||||
...(previousHash !== undefined ? { previousHash } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.storage.save(auditEvent);
|
||||
} catch (err) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.save.failed",
|
||||
{
|
||||
...(err instanceof Error ? { cause: err } : {}),
|
||||
context: { eventId: id },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
eventsLoggedCounter.add(1, {
|
||||
event_type: String(auditEvent.type),
|
||||
severity: auditEvent.severity,
|
||||
});
|
||||
|
||||
this.logger?.debug("Audit event logged", {
|
||||
eventId: id,
|
||||
eventType: String(auditEvent.type),
|
||||
severity: auditEvent.severity,
|
||||
});
|
||||
|
||||
this.bus?.emit(BUS_AUDIT_EVENT_LOGGED, auditEvent);
|
||||
|
||||
return auditEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries audit events using the provided filter options.
|
||||
*/
|
||||
public async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
|
||||
try {
|
||||
return await this.storage.query(options);
|
||||
} catch (err) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.query.failed",
|
||||
{ ...(err instanceof Error ? { cause: err } : {}) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the cryptographic chain integrity of an ordered event sequence.
|
||||
* Events must be provided in ascending chronological order.
|
||||
*/
|
||||
public async verifyChainIntegrity(
|
||||
events: AuditEvent[]
|
||||
): Promise<ChainVerificationResult> {
|
||||
return verifyChain(events, this.secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all audit events for a given user (GDPR Article 15/20).
|
||||
* Respects the optional AbortSignal for cancellation.
|
||||
*/
|
||||
public async exportGDPRData(
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GDPRDataExport> {
|
||||
if (signal?.aborted) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.EXPORT_FAILED,
|
||||
"audit.export.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
let auditEvents: AuditEvent[];
|
||||
try {
|
||||
auditEvents = await this.storage.query({ userId });
|
||||
} catch (err) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.EXPORT_FAILED,
|
||||
"audit.export.query.failed",
|
||||
{
|
||||
...(err instanceof Error ? { cause: err } : {}),
|
||||
context: { userId },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.EXPORT_FAILED,
|
||||
"audit.export.aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const exportedAt = new Date(this.clock());
|
||||
const eventTypes: Record<string, number> = {};
|
||||
let first: Date | undefined;
|
||||
let last: Date | undefined;
|
||||
|
||||
for (const evt of auditEvents) {
|
||||
const typeKey = String(evt.type);
|
||||
eventTypes[typeKey] = (eventTypes[typeKey] ?? 0) + 1;
|
||||
if (first === undefined || evt.timestamp < first)
|
||||
first = evt.timestamp;
|
||||
if (last === undefined || evt.timestamp > last)
|
||||
last = evt.timestamp;
|
||||
}
|
||||
|
||||
if (auditEvents.length === 0) {
|
||||
return {
|
||||
userId,
|
||||
exportedAt,
|
||||
auditEvents: [],
|
||||
summary: {
|
||||
totalEvents: 0,
|
||||
eventTypes: {},
|
||||
dateRange: { first: exportedAt, last: exportedAt },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
exportedAt,
|
||||
auditEvents,
|
||||
summary: {
|
||||
totalEvents: auditEvents.length,
|
||||
eventTypes,
|
||||
dateRange: { first: first as Date, last: last as Date },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the configured retention policy: archives then deletes expired events.
|
||||
* Respects the optional AbortSignal for cancellation.
|
||||
*/
|
||||
public async applyRetentionPolicy(
|
||||
signal?: AbortSignal
|
||||
): Promise<RetentionResult> {
|
||||
const result: RetentionResult = {
|
||||
eventsDeleted: 0,
|
||||
eventsArchived: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (this.retentionPolicy === undefined) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const policy = this.retentionPolicy;
|
||||
const retentionMs = policy.retentionDays * 24 * 60 * 60 * 1000;
|
||||
const cutoff = new Date(this.clock() - retentionMs);
|
||||
|
||||
if (
|
||||
policy.archiveBeforeDelete &&
|
||||
policy.archiveLocation !== undefined
|
||||
) {
|
||||
try {
|
||||
result.eventsArchived = await this.storage.archive(
|
||||
cutoff,
|
||||
policy.archiveLocation
|
||||
);
|
||||
} catch (err) {
|
||||
result.errors.push(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
result.eventsDeleted = await this.storage.deleteExpired(cutoff);
|
||||
} catch (err) {
|
||||
result.errors.push(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuditLogger } from "./audit-logger.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# chain-integrity
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the cryptographic chain-integrity subsystem for `@suaveplan/audit-logging`. Each audit event is signed with an HMAC-SHA256 digest that covers both the event's canonical JSON form and the hash of the preceding event, forming a tamper-evident linked chain. Any post-hoc modification to an event — or insertion/deletion of events — will cause `verifyChain` to detect the broken link at the exact index.
|
||||
|
||||
## Features
|
||||
|
||||
- `canonicalise` — deterministic JSON serialisation with sorted keys for cross-runtime stability
|
||||
- `computeEventHash` — HMAC-SHA256 over `previousHash:canonicalJSON` using `@suaveplan/crypto-hash`
|
||||
- `verifyChain` — iterates an ordered event sequence, recomputes hashes, and returns pass/fail with broken-at index
|
||||
- No `node:crypto` usage — all cryptography via `@suaveplan/crypto-hash`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeEventHash, verifyChain } from "@suaveplan/audit-logging/core/chain-integrity/chain-integrity";
|
||||
|
||||
const secretKey = "super-secret-key";
|
||||
|
||||
const eventPayload = {
|
||||
id: "evt-1",
|
||||
type: "auth_login",
|
||||
severity: "info",
|
||||
actor: { userId: "user-1" },
|
||||
timestamp: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
const hash = computeEventHash(eventPayload, secretKey);
|
||||
const event = { ...eventPayload, hash };
|
||||
|
||||
const result = verifyChain([event], secretKey);
|
||||
console.log(result.valid); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeEventHash, verifyChain } from "@suaveplan/audit-logging/core/chain-integrity/chain-integrity";
|
||||
|
||||
const secretKey = "my-secret";
|
||||
const events = [];
|
||||
|
||||
// Build chain
|
||||
let previousHash: string | undefined;
|
||||
for (const payload of rawPayloads) {
|
||||
const hash = computeEventHash(payload, secretKey, previousHash);
|
||||
const event = { ...payload, hash, ...(previousHash ? { previousHash } : {}) };
|
||||
events.push(event);
|
||||
previousHash = hash;
|
||||
}
|
||||
|
||||
// Verify chain
|
||||
const result = verifyChain(events, secretKey);
|
||||
if (!result.valid) {
|
||||
console.error(`Chain broken at index ${result.brokenAt}: ${result.reason}`);
|
||||
}
|
||||
|
||||
// Tamper detection example
|
||||
const tampered = [...events];
|
||||
tampered[2] = { ...tampered[2], actor: { userId: "attacker" } };
|
||||
const tamperResult = verifyChain(tampered, secretKey);
|
||||
console.log(tamperResult.brokenAt); // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `canonicalise(event): string`
|
||||
|
||||
Serialises an audit event to deterministic JSON with sorted keys, excluding `hash` and `previousHash`.
|
||||
|
||||
- `event` — event minus hash fields (all other fields serialised)
|
||||
- Returns: canonical JSON string
|
||||
|
||||
### `computeEventHash(event, secretKey, previousHash?): string`
|
||||
|
||||
Computes HMAC-SHA256 over the canonical representation.
|
||||
|
||||
- `event` — event without hash/previousHash
|
||||
- `secretKey` — UTF-8 signing key; must be non-empty
|
||||
- `previousHash?` — hex hash of preceding event; prepended as `${previousHash}:` before the canonical JSON
|
||||
- Returns: lowercase hex string (64 characters for SHA-256)
|
||||
|
||||
### `verifyChain(events, secretKey): ChainVerificationResult`
|
||||
|
||||
Verifies an ordered sequence of events.
|
||||
|
||||
- `events` — events in ascending chronological order
|
||||
- `secretKey` — the secret used when originally signing
|
||||
- Returns: `{ valid: true }` or `{ valid: false, brokenAt: number, reason: string }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Key sort in `canonicalise` is recursive via `sortReplacer` to handle nested objects like `target.metadata`. The actor object is sorted separately before the main sort pass to avoid double-sorting overhead. HMAC uses SHA-256 (not SHA-512) for a good balance of security and performance given the expected volume of audit events. The chain model does not enforce sequential ordering by `previousHash` linkage — it trusts the caller to provide events in order and simply recomputes each event's hash to detect any tampering. This design keeps verification O(n) without requiring a full DAG traversal.
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
|
||||
import type { AuditEvent } from "../../types/audit-logging.types.js";
|
||||
import {
|
||||
canonicalise,
|
||||
computeEventHash,
|
||||
verifyChain,
|
||||
} from "./chain-integrity.js";
|
||||
|
||||
function makeEventBase(
|
||||
overrides: Partial<Omit<AuditEvent, "hash" | "previousHash">> = {}
|
||||
) {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "user-1" },
|
||||
timestamp: new Date("2024-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(
|
||||
overrides: Partial<AuditEvent> = {},
|
||||
key = "secret"
|
||||
): AuditEvent {
|
||||
const base = makeEventBase(overrides);
|
||||
const hash = computeEventHash(base, key, overrides.previousHash);
|
||||
return {
|
||||
...base,
|
||||
hash,
|
||||
...(overrides.previousHash !== undefined
|
||||
? { previousHash: overrides.previousHash }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("canonicalise", () => {
|
||||
it("produces deterministic output", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase();
|
||||
expect(canonicalise(e)).toBe(canonicalise(e));
|
||||
});
|
||||
|
||||
it("sorts keys deterministically", { timeout: 5000 }, () => {
|
||||
const e1 = makeEventBase({
|
||||
actor: { userId: "u1", ipAddress: "1.2.3.4" },
|
||||
});
|
||||
const e2 = makeEventBase({
|
||||
actor: { ipAddress: "1.2.3.4", userId: "u1" },
|
||||
});
|
||||
expect(canonicalise(e1)).toBe(canonicalise(e2));
|
||||
});
|
||||
|
||||
it("includes target when present", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase({ target: { type: "doc", id: "d1" } });
|
||||
expect(canonicalise(e)).toContain("doc");
|
||||
});
|
||||
|
||||
it("includes details when present", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase({ details: { action: "view" } });
|
||||
expect(canonicalise(e)).toContain("view");
|
||||
});
|
||||
|
||||
it("includes tenantId when present", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase({ tenantId: "t1" });
|
||||
expect(canonicalise(e)).toContain("t1");
|
||||
});
|
||||
|
||||
it("includes correlationId when present", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase({ correlationId: "corr-1" });
|
||||
expect(canonicalise(e)).toContain("corr-1");
|
||||
});
|
||||
|
||||
it("excludes undefined optional fields", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase();
|
||||
const json = canonicalise(e);
|
||||
expect(json).not.toContain("target");
|
||||
expect(json).not.toContain("details");
|
||||
});
|
||||
|
||||
it("uses ISO timestamp", { timeout: 5000 }, () => {
|
||||
const ts = new Date("2024-06-15T10:30:00Z");
|
||||
const e = makeEventBase({ timestamp: ts });
|
||||
expect(canonicalise(e)).toContain("2024-06-15T10:30:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEventHash", () => {
|
||||
it("returns a hex string of 64 chars", { timeout: 5000 }, () => {
|
||||
const hash = computeEventHash(makeEventBase(), "secret");
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("is stable for same inputs", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase();
|
||||
expect(computeEventHash(e, "secret")).toBe(
|
||||
computeEventHash(e, "secret")
|
||||
);
|
||||
});
|
||||
|
||||
it("changes with different secret", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase();
|
||||
expect(computeEventHash(e, "secret1")).not.toBe(
|
||||
computeEventHash(e, "secret2")
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when event changes", { timeout: 5000 }, () => {
|
||||
const e1 = makeEventBase({ actor: { userId: "u1" } });
|
||||
const e2 = makeEventBase({ actor: { userId: "u2" } });
|
||||
expect(computeEventHash(e1, "secret")).not.toBe(
|
||||
computeEventHash(e2, "secret")
|
||||
);
|
||||
});
|
||||
|
||||
it("includes previousHash when provided", { timeout: 5000 }, () => {
|
||||
const e = makeEventBase();
|
||||
const withPrev = computeEventHash(e, "secret", "prevhash");
|
||||
const withoutPrev = computeEventHash(e, "secret");
|
||||
expect(withPrev).not.toBe(withoutPrev);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyChain", () => {
|
||||
it("returns valid for empty chain", { timeout: 5000 }, () => {
|
||||
expect(verifyChain([], "secret").valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns valid for single event", { timeout: 5000 }, () => {
|
||||
const event = makeEvent();
|
||||
expect(verifyChain([event], "secret").valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns valid for linked chain", { timeout: 5000 }, () => {
|
||||
const e1 = makeEvent({ id: "e1" });
|
||||
const e2 = makeEvent({ id: "e2", previousHash: e1.hash });
|
||||
const e3 = makeEvent({ id: "e3", previousHash: e2.hash });
|
||||
expect(verifyChain([e1, e2, e3], "secret").valid).toBe(true);
|
||||
});
|
||||
|
||||
it("detects tampered event field", { timeout: 5000 }, () => {
|
||||
const e1 = makeEvent({ id: "e1" });
|
||||
const e2 = makeEvent({ id: "e2", previousHash: e1.hash });
|
||||
const tampered: AuditEvent = { ...e2, actor: { userId: "attacker" } };
|
||||
const result = verifyChain([e1, tampered], "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.brokenAt).toBe(1);
|
||||
});
|
||||
|
||||
it("detects tampered hash directly", { timeout: 5000 }, () => {
|
||||
const e1 = makeEvent({ id: "e1" });
|
||||
const tampered: AuditEvent = {
|
||||
...e1,
|
||||
hash: "000000000000000000000000000000000000000000000000000000000000dead",
|
||||
};
|
||||
const result = verifyChain([tampered], "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.brokenAt).toBe(0);
|
||||
});
|
||||
|
||||
it("detects wrong secret", { timeout: 5000 }, () => {
|
||||
const event = makeEvent({}, "correct-secret");
|
||||
const result = verifyChain([event], "wrong-secret");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("includes reason in broken result", { timeout: 5000 }, () => {
|
||||
const e = makeEvent({ id: "test-id" });
|
||||
const tampered: AuditEvent = { ...e, hash: "badhash" };
|
||||
const result = verifyChain([tampered], "secret");
|
||||
expect(result.reason).toContain("test-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { toHex } from "@suaveplan/crypto-hash/encoding";
|
||||
import { hmac } from "@suaveplan/crypto-hash/mac";
|
||||
import type {
|
||||
AuditEvent,
|
||||
ChainVerificationResult,
|
||||
} from "../../types/audit-logging.types.js";
|
||||
|
||||
/** Text encoding used for HMAC key and data. */
|
||||
const enc = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Computes a canonical JSON representation of an event for HMAC signing.
|
||||
*
|
||||
* Fields are serialised with sorted keys to guarantee determinism across
|
||||
* runtimes and language boundaries. The `hash` and `previousHash` fields are
|
||||
* deliberately excluded from the digest so the signed payload does not depend
|
||||
* on itself.
|
||||
*/
|
||||
export function canonicalise(
|
||||
event: Omit<AuditEvent, "hash" | "previousHash">
|
||||
): string {
|
||||
const obj: Record<string, unknown> = {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
severity: event.severity,
|
||||
actor: sortedActor(event.actor),
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
};
|
||||
|
||||
const ev = event as Record<string, unknown>;
|
||||
if (ev["target"] !== undefined) {
|
||||
obj["target"] = ev["target"];
|
||||
}
|
||||
if (ev["details"] !== undefined) {
|
||||
obj["details"] = ev["details"];
|
||||
}
|
||||
if (ev["tenantId"] !== undefined) {
|
||||
obj["tenantId"] = ev["tenantId"];
|
||||
}
|
||||
if (ev["correlationId"] !== undefined) {
|
||||
obj["correlationId"] = ev["correlationId"];
|
||||
}
|
||||
|
||||
return JSON.stringify(obj, sortReplacer);
|
||||
}
|
||||
|
||||
function sortedActor(actor: AuditEvent["actor"]): Record<string, unknown> {
|
||||
const a: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(actor).sort()) {
|
||||
const val = (actor as Record<string, unknown>)[key];
|
||||
if (val !== undefined) {
|
||||
a[key] = val;
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function sortReplacer(_: string, value: unknown): unknown {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[k] = (value as Record<string, unknown>)[k];
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an HMAC-SHA256 over the canonical JSON form of the event.
|
||||
*
|
||||
* @param event - The audit event (hash/previousHash excluded from digest)
|
||||
* @param secretKey - UTF-8 key material; must be non-empty
|
||||
* @param previousHash - Optional hash of the previous event to include in chain
|
||||
* @returns Hex-encoded HMAC-SHA256 string
|
||||
*/
|
||||
export function computeEventHash(
|
||||
event: Omit<AuditEvent, "hash" | "previousHash">,
|
||||
secretKey: string,
|
||||
previousHash?: string
|
||||
): string {
|
||||
const canonical = canonicalise(event);
|
||||
const payload =
|
||||
previousHash !== undefined ? `${previousHash}:${canonical}` : canonical;
|
||||
const key = enc.encode(secretKey);
|
||||
const data = enc.encode(payload);
|
||||
const digest = hmac("SHA-256", key, data);
|
||||
return toHex(digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a sequence of audit events forms an intact HMAC chain.
|
||||
*
|
||||
* For each event at index `i`, this function recomputes the expected hash
|
||||
* and compares it to the stored `event.hash`. If they differ, the chain is
|
||||
* considered broken at that index.
|
||||
*
|
||||
* Events are expected to be provided in ascending chronological order
|
||||
* (as stored / returned by `query({ order: "asc" })`).
|
||||
*
|
||||
* @param events - Ordered list of events to verify
|
||||
* @param secretKey - The secret used during signing
|
||||
* @returns `ChainVerificationResult` with `valid: true` or `brokenAt` index
|
||||
*/
|
||||
export function verifyChain(
|
||||
events: AuditEvent[],
|
||||
secretKey: string
|
||||
): ChainVerificationResult {
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]!;
|
||||
const previousHash = event.previousHash;
|
||||
const expected = computeEventHash(event, secretKey, previousHash);
|
||||
|
||||
if (expected !== event.hash) {
|
||||
return {
|
||||
valid: false,
|
||||
brokenAt: i,
|
||||
reason: `Hash mismatch at index ${i}: event id=${event.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
canonicalise,
|
||||
computeEventHash,
|
||||
verifyChain,
|
||||
} from "./chain-integrity.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# audit-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a stable error-code registry and typed error class for `@suaveplan/audit-logging`. All package-level failures are represented as `AuditError` instances carrying a stable `AUDIT.*` code, enabling programmatic error handling and downstream telemetry without relying on message string parsing.
|
||||
|
||||
## Features
|
||||
|
||||
- `AUDIT_CODES` — frozen registry of six stable error codes via `defineCodes`
|
||||
- `AuditErrorCode` — union type of all valid AUDIT code strings
|
||||
- `AuditError` — extends `SuaveplanError<AuditErrorCode>` for full cause-chain support, OTel span events, and redaction
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
|
||||
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.save.failed",
|
||||
{ context: { table: "audit_events", operation: "insert" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
await adapter.save(event);
|
||||
} catch (err) {
|
||||
throw new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.save.failed",
|
||||
{
|
||||
...(err instanceof Error ? { cause: err } : {}),
|
||||
context: { eventId: event.id, eventType: event.type },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle downstream:
|
||||
try {
|
||||
await logger.log(auditEvent);
|
||||
} catch (err) {
|
||||
if (err instanceof AuditError && err.code === AUDIT_CODES.CHAIN_BROKEN) {
|
||||
console.error("Tamper detected:", err.context);
|
||||
}
|
||||
for (const cause of walkCauses(err)) {
|
||||
console.error(" caused by:", cause.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUDIT_CODES`
|
||||
|
||||
Frozen object with the following keys:
|
||||
|
||||
- `STORAGE_ERROR` → `"AUDIT.STORAGE_ERROR"` — storage backend operation failed
|
||||
- `CHAIN_BROKEN` → `"AUDIT.CHAIN_BROKEN"` — HMAC chain verification failed
|
||||
- `INVALID_EVENT` → `"AUDIT.INVALID_EVENT"` — event data failed schema validation
|
||||
- `EXPORT_FAILED` → `"AUDIT.EXPORT_FAILED"` — GDPR data export failed
|
||||
- `RETENTION_FAILED` → `"AUDIT.RETENTION_FAILED"` — retention policy application failed
|
||||
- `SECRET_KEY_MISSING` → `"AUDIT.SECRET_KEY_MISSING"` — no secret key provided for HMAC
|
||||
|
||||
### `AuditErrorCode`
|
||||
|
||||
Type alias: `"AUDIT.STORAGE_ERROR" | "AUDIT.CHAIN_BROKEN" | "AUDIT.INVALID_EVENT" | "AUDIT.EXPORT_FAILED" | "AUDIT.RETENTION_FAILED" | "AUDIT.SECRET_KEY_MISSING"`
|
||||
|
||||
### `AuditError`
|
||||
|
||||
`class AuditError extends SuaveplanError<AuditErrorCode>`
|
||||
|
||||
**Constructor:**
|
||||
|
||||
- `code: AuditErrorCode` — stable error code from `AUDIT_CODES`
|
||||
- `messageKey: string` — i18n message key (resolved by the global message resolver)
|
||||
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`, `clock`, `traceId`
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause` (from `SuaveplanError`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is called once at module load time with a `const` array, so TypeScript infers the exact literal union for `AuditErrorCode`. Using `as const` on the reasons array is essential — without it the inference falls back to `string[]` and the code type widens to `string`. The `AuditError` constructor delegates entirely to `SuaveplanError`; its only addition is setting `this.name = "AuditError"` for cleaner stack traces and `instanceof` checks.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUDIT_CODES, AuditError } from "./audit-error.js";
|
||||
|
||||
describe("AuditError", () => {
|
||||
it("creates error with correct code and name", { timeout: 5000 }, () => {
|
||||
const err = new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.failed"
|
||||
);
|
||||
expect(err.code).toBe("AUDIT.STORAGE_ERROR");
|
||||
expect(err.name).toBe("AuditError");
|
||||
expect(err).toBeInstanceOf(AuditError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("supports cause chaining", { timeout: 5000 }, () => {
|
||||
const cause = new Error("underlying db error");
|
||||
const err = new AuditError(
|
||||
AUDIT_CODES.STORAGE_ERROR,
|
||||
"audit.storage.failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("supports context", { timeout: 5000 }, () => {
|
||||
const err = new AuditError(
|
||||
AUDIT_CODES.INVALID_EVENT,
|
||||
"audit.event.invalid",
|
||||
{
|
||||
context: { eventId: "evt-123", field: "type" },
|
||||
}
|
||||
);
|
||||
expect(err.context).toEqual({ eventId: "evt-123", field: "type" });
|
||||
});
|
||||
|
||||
it("has all expected codes", { timeout: 5000 }, () => {
|
||||
expect(AUDIT_CODES.STORAGE_ERROR).toBe("AUDIT.STORAGE_ERROR");
|
||||
expect(AUDIT_CODES.CHAIN_BROKEN).toBe("AUDIT.CHAIN_BROKEN");
|
||||
expect(AUDIT_CODES.INVALID_EVENT).toBe("AUDIT.INVALID_EVENT");
|
||||
expect(AUDIT_CODES.EXPORT_FAILED).toBe("AUDIT.EXPORT_FAILED");
|
||||
expect(AUDIT_CODES.RETENTION_FAILED).toBe("AUDIT.RETENTION_FAILED");
|
||||
expect(AUDIT_CODES.SECRET_KEY_MISSING).toBe("AUDIT.SECRET_KEY_MISSING");
|
||||
});
|
||||
|
||||
it("codes object is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(AUDIT_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates error for each code variant", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(AUDIT_CODES)) {
|
||||
const err = new AuditError(code, "audit.generic");
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/audit-logging`.
|
||||
*
|
||||
* All codes are namespaced under `AUDIT` and registered once via
|
||||
* `defineCodes`, producing deterministic string identifiers that survive
|
||||
* tree-shaking and minification.
|
||||
*/
|
||||
export const AUDIT_CODES = defineCodes("AUDIT", [
|
||||
"STORAGE_ERROR",
|
||||
"CHAIN_BROKEN",
|
||||
"INVALID_EVENT",
|
||||
"EXPORT_FAILED",
|
||||
"RETENTION_FAILED",
|
||||
"SECRET_KEY_MISSING",
|
||||
] as const);
|
||||
|
||||
export type AuditErrorCode = (typeof AUDIT_CODES)[keyof typeof AUDIT_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for all `@suaveplan/audit-logging` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can use `instanceof AuditError`
|
||||
* for narrow discrimination while the full error chain, context, and OTel
|
||||
* span-event adapter remain available through the base class.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
|
||||
*
|
||||
* throw new AuditError(
|
||||
* AUDIT_CODES.STORAGE_ERROR,
|
||||
* "audit.storage.failed",
|
||||
* { context: { operation: "save" } }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class AuditError extends SuaveplanError<AuditErrorCode> {
|
||||
public constructor(
|
||||
code: AuditErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "AuditError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AUDIT_CODES, AuditError, type AuditErrorCode } from "./audit-error.js";
|
||||
51
packages/observability/monitor/audit-logging/src/index.ts
Normal file
51
packages/observability/monitor/audit-logging/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Constants
|
||||
|
||||
export { MemoryAuditAdapter } from "./adapters/memory/memory.js";
|
||||
// Adapters
|
||||
export { BaseAuditStorageAdapter } from "./adapters/storage/storage.js";
|
||||
export {
|
||||
AuditEventType,
|
||||
AuditSeverity,
|
||||
BUS_AUDIT_EVENT_LOGGED,
|
||||
DEFAULT_AUDIT_TABLE,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
OTEL_EVENTS_LOGGED,
|
||||
} from "./constants/index.js";
|
||||
// Core
|
||||
export { AuditLogger } from "./core/audit-logger/audit-logger.js";
|
||||
export {
|
||||
canonicalise,
|
||||
computeEventHash,
|
||||
verifyChain,
|
||||
} from "./core/chain-integrity/chain-integrity.js";
|
||||
// Errors
|
||||
export {
|
||||
AUDIT_CODES,
|
||||
AuditError,
|
||||
type AuditErrorCode,
|
||||
} from "./errors/audit-error.js";
|
||||
// Schemas
|
||||
export {
|
||||
AuditActorSchema,
|
||||
AuditEventSchema,
|
||||
AuditEventTypeSchema,
|
||||
AuditQueryOptionsSchema,
|
||||
AuditRetentionPolicySchema,
|
||||
AuditSeveritySchema,
|
||||
AuditTargetSchema,
|
||||
ChainVerificationResultSchema,
|
||||
RetentionResultSchema,
|
||||
} from "./schemas/audit-logging.schemas.js";
|
||||
// Types
|
||||
export type {
|
||||
AuditActor,
|
||||
AuditEvent,
|
||||
AuditLoggerOptions,
|
||||
AuditQueryOptions,
|
||||
AuditRetentionPolicy,
|
||||
AuditStorageAdapter,
|
||||
AuditTarget,
|
||||
ChainVerificationResult,
|
||||
GDPRDataExport,
|
||||
RetentionResult,
|
||||
} from "./types/audit-logging.types.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# audit-logging.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod v4 schema definitions for all public types in `@suaveplan/audit-logging`. These schemas enable runtime validation at API boundaries, wire deserialization, and property-based testing. Every schema mirrors its corresponding TypeScript interface exactly, ensuring type inference from `z.infer<>` produces the same shape as the hand-written types in `audit-logging.types.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuditSeveritySchema` — enum validator for the five severity levels
|
||||
- `AuditEventTypeSchema` — enum validator for all 29 built-in event types
|
||||
- `AuditActorSchema` — optional-field object schema for actors
|
||||
- `AuditTargetSchema` — required type/id with optional metadata record
|
||||
- `AuditEventSchema` — complete event record with all fields
|
||||
- `AuditQueryOptionsSchema` — query filter schema with union severity support
|
||||
- `AuditRetentionPolicySchema` — policy with positive retentionDays validation
|
||||
- `ChainVerificationResultSchema` — pass/fail result with optional broken-at index
|
||||
- `RetentionResultSchema` — counts and errors array
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AuditEventSchema } from "@suaveplan/audit-logging/schemas/audit-logging.schemas";
|
||||
|
||||
const raw = JSON.parse(apiResponse);
|
||||
const event = AuditEventSchema.parse({
|
||||
...raw,
|
||||
timestamp: new Date(raw.timestamp),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AuditQueryOptionsSchema,
|
||||
AuditRetentionPolicySchema,
|
||||
} from "@suaveplan/audit-logging/schemas/audit-logging.schemas";
|
||||
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
|
||||
// Validate query options from user input
|
||||
function parseQueryOptions(input: unknown) {
|
||||
return AuditQueryOptionsSchema.parse(input);
|
||||
}
|
||||
|
||||
// Validate policy from config file
|
||||
function parsePolicy(input: unknown) {
|
||||
const policy = AuditRetentionPolicySchema.parse(input);
|
||||
return new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "key",
|
||||
retentionPolicy: policy,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuditSeveritySchema`
|
||||
|
||||
`z.enum(["debug", "info", "warning", "error", "critical"])`
|
||||
|
||||
### `AuditEventTypeSchema`
|
||||
|
||||
`z.enum([...all 29 values...])`
|
||||
|
||||
### `AuditActorSchema`
|
||||
|
||||
All fields optional: `userId`, `sessionId`, `apiKeyId`, `ipAddress`, `userAgent`, `tenantId`.
|
||||
|
||||
### `AuditTargetSchema`
|
||||
|
||||
- `type: string` (required)
|
||||
- `id: string` (required)
|
||||
- `metadata?: Record<string, unknown>`
|
||||
|
||||
### `AuditEventSchema`
|
||||
|
||||
- All AuditEvent fields with proper Zod types
|
||||
- `timestamp: z.date()` — expects a `Date` object (not a string)
|
||||
- `type: z.union([AuditEventTypeSchema, z.string()])` — accepts custom types
|
||||
|
||||
### `AuditQueryOptionsSchema`
|
||||
|
||||
All fields optional. `severity` accepts a single value or array.
|
||||
|
||||
### `AuditRetentionPolicySchema`
|
||||
|
||||
- `retentionDays: z.number().int().positive()` — must be ≥ 1
|
||||
- `archiveBeforeDelete: z.boolean()`
|
||||
- `archiveLocation?: z.string()`
|
||||
|
||||
### `ChainVerificationResultSchema`
|
||||
|
||||
- `valid: z.boolean()`
|
||||
- `brokenAt?: z.number().int().nonnegative()`
|
||||
- `reason?: z.string()`
|
||||
|
||||
### `RetentionResultSchema`
|
||||
|
||||
- `eventsDeleted: z.number().int().nonnegative()`
|
||||
- `eventsArchived: z.number().int().nonnegative()`
|
||||
- `errors: z.array(z.string())`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Schemas use `z.record(z.string(), z.unknown())` (two-argument form) per Zod v4 requirements. The `AuditEventTypeSchema` enumerates all values from the `AuditEventType` enum directly rather than using `z.nativeEnum()`, which is deprecated in Zod v4 in favour of `z.enum()`. The `timestamp` field expects an actual `Date` object — callers parsing JSON should convert the ISO string first with `new Date(raw.timestamp)`.
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AuditEventType, AuditSeverity } from "../constants/index.js";
|
||||
import {
|
||||
AuditActorSchema,
|
||||
AuditEventSchema,
|
||||
AuditEventTypeSchema,
|
||||
AuditQueryOptionsSchema,
|
||||
AuditRetentionPolicySchema,
|
||||
AuditSeveritySchema,
|
||||
AuditTargetSchema,
|
||||
ChainVerificationResultSchema,
|
||||
RetentionResultSchema,
|
||||
} from "./audit-logging.schemas.js";
|
||||
|
||||
describe("AuditSeveritySchema", () => {
|
||||
it("accepts valid values", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(AuditSeverity)) {
|
||||
expect(AuditSeveritySchema.parse(v)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid value", { timeout: 5000 }, () => {
|
||||
expect(() => AuditSeveritySchema.parse("trace")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditEventTypeSchema", () => {
|
||||
it("accepts all built-in event types", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(AuditEventType)) {
|
||||
expect(AuditEventTypeSchema.parse(v)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown type", { timeout: 5000 }, () => {
|
||||
expect(() => AuditEventTypeSchema.parse("not_a_type")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditActorSchema", () => {
|
||||
it("accepts empty object", { timeout: 5000 }, () => {
|
||||
expect(AuditActorSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts all fields", { timeout: 5000 }, () => {
|
||||
const actor = {
|
||||
userId: "u1",
|
||||
sessionId: "s1",
|
||||
apiKeyId: "k1",
|
||||
ipAddress: "1.2.3.4",
|
||||
userAgent: "Mozilla",
|
||||
tenantId: "t1",
|
||||
};
|
||||
expect(AuditActorSchema.parse(actor)).toEqual(actor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditTargetSchema", () => {
|
||||
it("accepts required fields", { timeout: 5000 }, () => {
|
||||
expect(AuditTargetSchema.parse({ type: "doc", id: "d1" })).toEqual({
|
||||
type: "doc",
|
||||
id: "d1",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts metadata", { timeout: 5000 }, () => {
|
||||
const t = { type: "doc", id: "d1", metadata: { version: 2 } };
|
||||
expect(AuditTargetSchema.parse(t)).toEqual(t);
|
||||
});
|
||||
|
||||
it("rejects missing required fields", { timeout: 5000 }, () => {
|
||||
expect(() => AuditTargetSchema.parse({ type: "doc" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditEventSchema", () => {
|
||||
const base = {
|
||||
id: "evt-1",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
timestamp: new Date("2024-01-01"),
|
||||
hash: "deadbeef",
|
||||
};
|
||||
|
||||
it("accepts valid event", { timeout: 5000 }, () => {
|
||||
const result = AuditEventSchema.parse(base);
|
||||
expect(result.id).toBe("evt-1");
|
||||
});
|
||||
|
||||
it("accepts custom event type string via union", { timeout: 5000 }, () => {
|
||||
const result = AuditEventSchema.parse({
|
||||
...base,
|
||||
type: "custom_event",
|
||||
});
|
||||
expect(result.type).toBe("custom_event");
|
||||
});
|
||||
|
||||
it("accepts all optional fields", { timeout: 5000 }, () => {
|
||||
const event = {
|
||||
...base,
|
||||
target: { type: "doc", id: "d1" },
|
||||
details: { key: "val" },
|
||||
previousHash: "prev",
|
||||
tenantId: "t1",
|
||||
correlationId: "corr",
|
||||
};
|
||||
expect(AuditEventSchema.parse(event)).toEqual(event);
|
||||
});
|
||||
|
||||
it("rejects missing required fields", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuditEventSchema.parse({ ...base, id: undefined })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditQueryOptionsSchema", () => {
|
||||
it("accepts empty object", { timeout: 5000 }, () => {
|
||||
expect(AuditQueryOptionsSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts severity as single value", { timeout: 5000 }, () => {
|
||||
const r = AuditQueryOptionsSchema.parse({
|
||||
severity: AuditSeverity.ERROR,
|
||||
});
|
||||
expect(r.severity).toBe(AuditSeverity.ERROR);
|
||||
});
|
||||
|
||||
it("accepts severity as array", { timeout: 5000 }, () => {
|
||||
const r = AuditQueryOptionsSchema.parse({
|
||||
severity: [AuditSeverity.ERROR, AuditSeverity.CRITICAL],
|
||||
});
|
||||
expect(Array.isArray(r.severity)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditRetentionPolicySchema", () => {
|
||||
it("accepts valid policy", { timeout: 5000 }, () => {
|
||||
const p = {
|
||||
retentionDays: 90,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://bucket",
|
||||
};
|
||||
expect(AuditRetentionPolicySchema.parse(p)).toEqual(p);
|
||||
});
|
||||
|
||||
it("rejects retentionDays = 0", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
AuditRetentionPolicySchema.parse({
|
||||
retentionDays: 0,
|
||||
archiveBeforeDelete: false,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainVerificationResultSchema", () => {
|
||||
it("accepts valid result", { timeout: 5000 }, () => {
|
||||
expect(ChainVerificationResultSchema.parse({ valid: true })).toEqual({
|
||||
valid: true,
|
||||
});
|
||||
expect(
|
||||
ChainVerificationResultSchema.parse({
|
||||
valid: false,
|
||||
brokenAt: 3,
|
||||
reason: "mismatch",
|
||||
})
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RetentionResultSchema", () => {
|
||||
it("accepts valid result", { timeout: 5000 }, () => {
|
||||
const r = {
|
||||
eventsDeleted: 5,
|
||||
eventsArchived: 3,
|
||||
errors: ["some error"],
|
||||
};
|
||||
expect(RetentionResultSchema.parse(r)).toEqual(r);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import z from "zod";
|
||||
import { AuditEventType, AuditSeverity } from "../constants/index.js";
|
||||
|
||||
/** Zod schema for AuditSeverity. */
|
||||
export const AuditSeveritySchema = z.enum([
|
||||
AuditSeverity.DEBUG,
|
||||
AuditSeverity.INFO,
|
||||
AuditSeverity.WARNING,
|
||||
AuditSeverity.ERROR,
|
||||
AuditSeverity.CRITICAL,
|
||||
]);
|
||||
|
||||
/** Zod schema for AuditEventType. */
|
||||
export const AuditEventTypeSchema = z.enum([
|
||||
AuditEventType.AUTH_LOGIN,
|
||||
AuditEventType.AUTH_LOGOUT,
|
||||
AuditEventType.AUTH_REGISTER,
|
||||
AuditEventType.AUTH_PASSWORD_CHANGE,
|
||||
AuditEventType.AUTH_PASSWORD_RESET,
|
||||
AuditEventType.AUTH_MFA_ENABLE,
|
||||
AuditEventType.AUTH_MFA_DISABLE,
|
||||
AuditEventType.AUTH_SESSION_CREATE,
|
||||
AuditEventType.AUTH_SESSION_REVOKE,
|
||||
AuditEventType.AUTH_API_KEY_CREATE,
|
||||
AuditEventType.AUTH_API_KEY_REVOKE,
|
||||
AuditEventType.AUTHZ_PERMISSION_CHECK,
|
||||
AuditEventType.AUTHZ_PERMISSION_GRANT,
|
||||
AuditEventType.AUTHZ_PERMISSION_REVOKE,
|
||||
AuditEventType.AUTHZ_ROLE_ASSIGN,
|
||||
AuditEventType.AUTHZ_ROLE_UNASSIGN,
|
||||
AuditEventType.DATA_CREATE,
|
||||
AuditEventType.DATA_READ,
|
||||
AuditEventType.DATA_UPDATE,
|
||||
AuditEventType.DATA_DELETE,
|
||||
AuditEventType.DATA_EXPORT,
|
||||
AuditEventType.DATA_IMPORT,
|
||||
AuditEventType.SYSTEM_CONFIG_CHANGE,
|
||||
AuditEventType.SYSTEM_SECURITY_ALERT,
|
||||
AuditEventType.SYSTEM_RATE_LIMIT,
|
||||
AuditEventType.SYSTEM_ERROR,
|
||||
AuditEventType.GDPR_DATA_EXPORT,
|
||||
AuditEventType.GDPR_DATA_DELETE,
|
||||
AuditEventType.GDPR_CONSENT_CHANGE,
|
||||
]);
|
||||
|
||||
/** Zod schema for AuditActor. */
|
||||
export const AuditActorSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
apiKeyId: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
tenantId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for AuditTarget. */
|
||||
export const AuditTargetSchema = z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for a complete AuditEvent. */
|
||||
export const AuditEventSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.union([AuditEventTypeSchema, z.string()]),
|
||||
severity: AuditSeveritySchema,
|
||||
actor: AuditActorSchema,
|
||||
target: AuditTargetSchema.optional(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
timestamp: z.date(),
|
||||
previousHash: z.string().optional(),
|
||||
hash: z.string(),
|
||||
tenantId: z.string().optional(),
|
||||
correlationId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for AuditQueryOptions. */
|
||||
export const AuditQueryOptionsSchema = z.object({
|
||||
eventTypes: z.array(AuditEventTypeSchema).optional(),
|
||||
userId: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
severity: z
|
||||
.union([AuditSeveritySchema, z.array(AuditSeveritySchema)])
|
||||
.optional(),
|
||||
targetType: z.string().optional(),
|
||||
targetId: z.string().optional(),
|
||||
startTime: z.date().optional(),
|
||||
endTime: z.date().optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
tenantId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for AuditRetentionPolicy. */
|
||||
export const AuditRetentionPolicySchema = z.object({
|
||||
retentionDays: z.number().int().positive(),
|
||||
archiveBeforeDelete: z.boolean(),
|
||||
archiveLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for ChainVerificationResult. */
|
||||
export const ChainVerificationResultSchema = z.object({
|
||||
valid: z.boolean(),
|
||||
brokenAt: z.number().int().nonnegative().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for RetentionResult. */
|
||||
export const RetentionResultSchema = z.object({
|
||||
eventsDeleted: z.number().int().nonnegative(),
|
||||
eventsArchived: z.number().int().nonnegative(),
|
||||
errors: z.array(z.string()),
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
# audit-logging.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the complete TypeScript type system for `@suaveplan/audit-logging`. These types form the public contract for all audit event data, query filtering, storage adapters, GDPR exports, and chain-integrity verification. Keeping them in a dedicated types file ensures zero runtime overhead — types are erased during compilation, and consumers can import only what they need.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuditActor` — who triggered an event (user, session, API key, IP, tenant)
|
||||
- `AuditTarget` — the resource acted upon (type, id, optional metadata)
|
||||
- `AuditEvent` — complete immutable event record including HMAC chain fields
|
||||
- `AuditQueryOptions` — rich filtering: event types, user/session, severity, time range, pagination
|
||||
- `AuditRetentionPolicy` — configurable TTL with archive-before-delete support
|
||||
- `AuditLoggerOptions` — injectable clock, ID generator, logger, event bus, and storage adapter
|
||||
- `AuditStorageAdapter` — pluggable backend interface (save, query, count, archive, deleteExpired)
|
||||
- `GDPRDataExport` — structured export with event list and summary statistics
|
||||
- `ChainVerificationResult` — pass/fail with broken-at index and reason string
|
||||
- `RetentionResult` — counts of deleted/archived events plus any errors
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { AuditEvent, AuditActor, AuditTarget } from "@suaveplan/audit-logging";
|
||||
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
|
||||
const actor: AuditActor = {
|
||||
userId: "user-123",
|
||||
ipAddress: "203.0.113.1",
|
||||
};
|
||||
|
||||
const target: AuditTarget = {
|
||||
type: "document",
|
||||
id: "doc-456",
|
||||
};
|
||||
|
||||
const event: AuditEvent = {
|
||||
id: "evt-789",
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor,
|
||||
target,
|
||||
timestamp: new Date(),
|
||||
hash: "computed-hmac",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { AuditQueryOptions, AuditRetentionPolicy, AuditLoggerOptions } from "@suaveplan/audit-logging";
|
||||
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
|
||||
|
||||
const query: AuditQueryOptions = {
|
||||
eventTypes: [AuditEventType.AUTH_LOGIN, AuditEventType.AUTH_LOGOUT],
|
||||
severity: [AuditSeverity.WARNING, AuditSeverity.ERROR, AuditSeverity.CRITICAL],
|
||||
startTime: new Date("2024-01-01"),
|
||||
endTime: new Date("2024-12-31"),
|
||||
tenantId: "tenant-abc",
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
order: "desc",
|
||||
};
|
||||
|
||||
const retention: AuditRetentionPolicy = {
|
||||
retentionDays: 90,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://my-bucket/audit-archive",
|
||||
};
|
||||
|
||||
const options: AuditLoggerOptions = {
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "my-secret-key",
|
||||
enableChainIntegrity: true,
|
||||
retentionPolicy: retention,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuditActor`
|
||||
|
||||
All fields optional. Represents the entity that performed the action.
|
||||
|
||||
- `userId?: string` — authenticated user identifier
|
||||
- `sessionId?: string` — active session identifier
|
||||
- `apiKeyId?: string` — API key used for the request
|
||||
- `ipAddress?: string` — source IP address
|
||||
- `userAgent?: string` — HTTP User-Agent header
|
||||
- `tenantId?: string` — tenant in multi-tenant deployments
|
||||
|
||||
### `AuditTarget`
|
||||
|
||||
- `type: string` — resource type (e.g. `"document"`, `"user"`)
|
||||
- `id: string` — resource identifier
|
||||
- `metadata?: Record<string, unknown>` — additional context
|
||||
|
||||
### `AuditEvent`
|
||||
|
||||
- `id: string` — unique event identifier
|
||||
- `type: AuditEventType | string` — event type from the taxonomy or custom string
|
||||
- `severity: AuditSeverity` — debug/info/warning/error/critical
|
||||
- `actor: AuditActor` — who triggered the event
|
||||
- `target?: AuditTarget` — optional target resource
|
||||
- `details?: Record<string, unknown>` — arbitrary event-specific data
|
||||
- `timestamp: Date` — when the event occurred
|
||||
- `previousHash?: string` — HMAC of the preceding event (chain integrity)
|
||||
- `hash: string` — HMAC-SHA256 of this event's canonical form
|
||||
- `tenantId?: string` — tenant scoping
|
||||
- `correlationId?: string` — request/trace correlation
|
||||
|
||||
### `AuditQueryOptions`
|
||||
|
||||
Filtering fields for `AuditStorageAdapter.query()`. All optional.
|
||||
|
||||
### `AuditStorageAdapter`
|
||||
|
||||
- `save(event)` — persist a single event
|
||||
- `query(options)` — filter and paginate events
|
||||
- `getLatestEvent(tenantId?)` — most recent event for chain linking
|
||||
- `deleteExpired(before)` — remove old events; returns count deleted
|
||||
- `archive(before, location)` — copy then mark events; returns count archived
|
||||
- `count(options?)` — count matching events
|
||||
|
||||
### `GDPRDataExport`
|
||||
|
||||
- `userId: string` — the data subject
|
||||
- `exportedAt: Date` — export timestamp
|
||||
- `auditEvents: AuditEvent[]` — all events involving the user
|
||||
- `summary` — `totalEvents`, `eventTypes` map, `dateRange`
|
||||
|
||||
### `ChainVerificationResult`
|
||||
|
||||
- `valid: boolean` — whether chain is intact
|
||||
- `brokenAt?: number` — zero-based index of first broken link
|
||||
- `reason?: string` — human-readable explanation
|
||||
|
||||
### `RetentionResult`
|
||||
|
||||
- `eventsDeleted: number` — count of hard-deleted events
|
||||
- `eventsArchived: number` — count of archived events
|
||||
- `errors: string[]` — non-fatal errors encountered
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types are defined as interfaces rather than classes to allow multiple implementations of `AuditStorageAdapter` without prototype overhead. The `exactOptionalPropertyTypes` constraint means all optional fields use `?:` and callers must use spread syntax to conditionally include them. The `AuditEvent.hash` field is always a hex-encoded HMAC-SHA256 string, computed by `computeEventHash` in `chain-integrity.ts`.
|
||||
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
Clock,
|
||||
EventBusLike,
|
||||
IdGenerator,
|
||||
LoggerLike,
|
||||
} from "@suaveplan/types";
|
||||
import type { AuditEventType, AuditSeverity } from "../constants/index.js";
|
||||
|
||||
/**
|
||||
* Represents the actor who triggered an audit event.
|
||||
* All fields are optional to support system-initiated actions with no human actor.
|
||||
*/
|
||||
export interface AuditActor {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the resource targeted by an audit event.
|
||||
*/
|
||||
export interface AuditTarget {
|
||||
type: string;
|
||||
id: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An immutable, signed audit event record.
|
||||
* The `hash` field contains an HMAC-SHA256 over the canonical form of the event.
|
||||
* The `previousHash` field forms a cryptographic chain linking events.
|
||||
*/
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
type: AuditEventType | string;
|
||||
severity: AuditSeverity;
|
||||
actor: AuditActor;
|
||||
target?: AuditTarget;
|
||||
details?: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
previousHash?: string;
|
||||
hash: string;
|
||||
tenantId?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering and paginating audit event queries.
|
||||
*/
|
||||
export interface AuditQueryOptions {
|
||||
eventTypes?: AuditEventType[];
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
severity?: AuditSeverity | AuditSeverity[];
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order?: "asc" | "desc";
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy governing how long audit events are retained and whether they
|
||||
* should be archived before deletion.
|
||||
*/
|
||||
export interface AuditRetentionPolicy {
|
||||
retentionDays: number;
|
||||
archiveBeforeDelete: boolean;
|
||||
archiveLocation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for AuditLogger.
|
||||
*/
|
||||
export interface AuditLoggerOptions {
|
||||
storage: AuditStorageAdapter;
|
||||
secretKey: string;
|
||||
tableName?: string;
|
||||
retentionPolicy?: AuditRetentionPolicy;
|
||||
enableChainIntegrity?: boolean;
|
||||
clock?: Clock;
|
||||
idGenerator?: IdGenerator;
|
||||
logger?: LoggerLike;
|
||||
bus?: EventBusLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable storage backend for audit events.
|
||||
* Implementations include MemoryAuditAdapter, SQLite, SurrealDB, etc.
|
||||
*/
|
||||
export interface AuditStorageAdapter {
|
||||
save(event: AuditEvent): Promise<void>;
|
||||
query(options: AuditQueryOptions): Promise<AuditEvent[]>;
|
||||
getLatestEvent(tenantId?: string): Promise<AuditEvent | null>;
|
||||
deleteExpired(before: Date): Promise<number>;
|
||||
archive(before: Date, location: string): Promise<number>;
|
||||
count(options?: Partial<AuditQueryOptions>): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a GDPR data export request for a specific user.
|
||||
*/
|
||||
export interface GDPRDataExport {
|
||||
userId: string;
|
||||
exportedAt: Date;
|
||||
auditEvents: AuditEvent[];
|
||||
summary: {
|
||||
totalEvents: number;
|
||||
eventTypes: Record<string, number>;
|
||||
dateRange: { first: Date; last: Date };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of chain integrity verification on a sequence of audit events.
|
||||
*/
|
||||
export interface ChainVerificationResult {
|
||||
valid: boolean;
|
||||
brokenAt?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying the retention policy.
|
||||
*/
|
||||
export interface RetentionResult {
|
||||
eventsDeleted: number;
|
||||
eventsArchived: number;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryAuditAdapter } from "../src/adapters/memory/memory.js";
|
||||
import { AuditEventType, AuditSeverity } from "../src/constants/index.js";
|
||||
import { AuditLogger } from "../src/core/audit-logger/audit-logger.js";
|
||||
|
||||
describe("AuditLogger integration", () => {
|
||||
let adapter: MemoryAuditAdapter;
|
||||
let logger: AuditLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new MemoryAuditAdapter();
|
||||
logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "integration-secret-key",
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("full lifecycle: log, query, verify, export", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Log several events
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
target: { type: "report", id: "r1" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGOUT,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "alice" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "bob" },
|
||||
});
|
||||
|
||||
// Query all events
|
||||
const all = await logger.query({});
|
||||
expect(all.length).toBe(4);
|
||||
|
||||
// Query by user
|
||||
const aliceEvents = await logger.query({ userId: "alice" });
|
||||
expect(aliceEvents.length).toBe(3);
|
||||
|
||||
// Verify chain integrity
|
||||
const ordered = await logger.query({ order: "asc" });
|
||||
const verifyResult = await logger.verifyChainIntegrity(ordered);
|
||||
expect(verifyResult.valid).toBe(true);
|
||||
|
||||
// GDPR export
|
||||
const gdprExport = await logger.exportGDPRData("alice");
|
||||
expect(gdprExport.userId).toBe("alice");
|
||||
expect(gdprExport.summary.totalEvents).toBe(3);
|
||||
expect(gdprExport.summary.eventTypes[AuditEventType.AUTH_LOGIN]).toBe(
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it("chain links correctly across multiple events", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let tick = 0;
|
||||
const chainLogger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "integration-secret-key",
|
||||
enableChainIntegrity: true,
|
||||
clock: () => new Date("2024-01-01").getTime() + tick++ * 1000,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
events.push(
|
||||
await chainLogger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: `user-${i}` },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Verify each event links to previous
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i]?.previousHash).toBe(events[i - 1]?.hash);
|
||||
}
|
||||
|
||||
const stored = await chainLogger.query({ order: "asc" });
|
||||
const chainEvents = stored.slice(-5); // last 5 we logged
|
||||
const result = await chainLogger.verifyChainIntegrity(chainEvents);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("retention policy lifecycle", { timeout: 30000 }, async () => {
|
||||
const now = new Date("2024-12-31").getTime();
|
||||
const retentionLogger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "secret",
|
||||
enableChainIntegrity: false,
|
||||
retentionPolicy: {
|
||||
retentionDays: 1,
|
||||
archiveBeforeDelete: true,
|
||||
archiveLocation: "s3://archive",
|
||||
},
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
// Add old event
|
||||
await adapter.save({
|
||||
id: "old-event",
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: {},
|
||||
timestamp: new Date("2024-01-01"),
|
||||
hash: "h",
|
||||
});
|
||||
|
||||
const result = await retentionLogger.applyRetentionPolicy();
|
||||
expect(result.eventsArchived).toBe(1);
|
||||
expect(result.eventsDeleted).toBe(0); // already moved to archive
|
||||
expect(adapter.snapshot().length).toBe(0);
|
||||
expect(adapter.archivedSnapshot().length).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-tenant chain isolation", { timeout: 30000 }, async () => {
|
||||
// Each tenant gets its own chain: getLatestEvent(tenantId) scopes the lookup
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
tenantId: "t1",
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u2" },
|
||||
tenantId: "t2",
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
tenantId: "t1",
|
||||
});
|
||||
|
||||
const t1Events = await logger.query({ tenantId: "t1", order: "asc" });
|
||||
const t2Events = await logger.query({ tenantId: "t2", order: "asc" });
|
||||
|
||||
// First t1 event has no predecessor → no previousHash
|
||||
expect(t1Events[0]?.previousHash).toBeUndefined();
|
||||
// Second t1 event links to first t1 event
|
||||
expect(t1Events[1]?.previousHash).toBe(t1Events[0]?.hash);
|
||||
// t2 only has one event, so no previousHash
|
||||
expect(t2Events[0]?.previousHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tamper detection in integration", { timeout: 30000 }, async () => {
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
await logger.log({
|
||||
type: AuditEventType.DATA_READ,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
|
||||
const events = await logger.query({ order: "asc" });
|
||||
|
||||
// Tamper with first event
|
||||
const tampered = [...events];
|
||||
tampered[0] = { ...events[0]!, actor: { userId: "attacker" } };
|
||||
|
||||
const result = await logger.verifyChainIntegrity(tampered);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.brokenAt).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { MemoryAuditAdapter } from "../src/adapters/memory/memory.js";
|
||||
import { AuditEventType, AuditSeverity } from "../src/constants/index.js";
|
||||
import { AuditLogger } from "../src/core/audit-logger/audit-logger.js";
|
||||
import {
|
||||
computeEventHash,
|
||||
verifyChain,
|
||||
} from "../src/core/chain-integrity/chain-integrity.js";
|
||||
import type { AuditEvent } from "../src/types/audit-logging.types.js";
|
||||
|
||||
const eventBaseArb = fc.record({
|
||||
id: fc.uuid(),
|
||||
type: fc.constantFrom(...Object.values(AuditEventType)),
|
||||
severity: fc.constantFrom(...Object.values(AuditSeverity)),
|
||||
actor: fc.record({
|
||||
userId: fc.option(fc.uuid(), { nil: undefined }),
|
||||
sessionId: fc.option(fc.uuid(), { nil: undefined }),
|
||||
}),
|
||||
timestamp: fc.date({
|
||||
min: new Date("2020-01-01"),
|
||||
max: new Date("2030-01-01"),
|
||||
}),
|
||||
});
|
||||
|
||||
describe("property: HMAC stability", () => {
|
||||
it("same inputs produce same hash", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
eventBaseArb,
|
||||
fc.string({ minLength: 1 }),
|
||||
(event, secret) => {
|
||||
const h1 = computeEventHash(event, secret);
|
||||
const h2 = computeEventHash(event, secret);
|
||||
return h1 === h2;
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("different secrets produce different hashes", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
eventBaseArb,
|
||||
fc.string({ minLength: 1 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
(event, s1, s2) => {
|
||||
fc.pre(s1 !== s2);
|
||||
return (
|
||||
computeEventHash(event, s1) !==
|
||||
computeEventHash(event, s2)
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("different events produce different hashes (same secret)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
eventBaseArb,
|
||||
eventBaseArb,
|
||||
fc.string({ minLength: 1 }),
|
||||
(e1, e2, secret) => {
|
||||
// Only guarantee different hashes when key fields differ
|
||||
fc.pre(e1.id !== e2.id);
|
||||
return (
|
||||
computeEventHash(e1, secret) !==
|
||||
computeEventHash(e2, secret)
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: chain integrity mutation detection", () => {
|
||||
it("single mutation breaks chain at mutation index", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(eventBaseArb, { minLength: 2, maxLength: 5 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
async (bases, secret) => {
|
||||
// Build a valid chain
|
||||
const events: AuditEvent[] = [];
|
||||
let previousHash: string | undefined;
|
||||
for (const base of bases) {
|
||||
const hash = computeEventHash(
|
||||
base,
|
||||
secret,
|
||||
previousHash
|
||||
);
|
||||
const event: AuditEvent = {
|
||||
...base,
|
||||
hash,
|
||||
...(previousHash !== undefined
|
||||
? { previousHash }
|
||||
: {}),
|
||||
};
|
||||
events.push(event);
|
||||
previousHash = hash;
|
||||
}
|
||||
|
||||
const validResult = verifyChain(events, secret);
|
||||
if (!validResult.valid) return false;
|
||||
|
||||
// Tamper with a random event (change userId)
|
||||
const tamperIdx = 0;
|
||||
const tampered = [...events];
|
||||
tampered[tamperIdx] = {
|
||||
...events[tamperIdx]!,
|
||||
actor: { userId: "tampered-user" },
|
||||
};
|
||||
|
||||
const brokenResult = verifyChain(tampered, secret);
|
||||
return (
|
||||
!brokenResult.valid &&
|
||||
brokenResult.brokenAt === tamperIdx
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: query filter correctness", () => {
|
||||
it("userId filter returns only matching events", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.uuid(), { minLength: 1, maxLength: 3 }),
|
||||
async (userIds) => {
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "s",
|
||||
});
|
||||
|
||||
for (const userId of userIds) {
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
for (const userId of userIds) {
|
||||
const results = await logger.query({ userId });
|
||||
return results.every((e) => e.actor.userId === userId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it("limit never exceeds requested count", { timeout: 30000 }, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
async (total, limit) => {
|
||||
const adapter = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage: adapter,
|
||||
secretKey: "s",
|
||||
});
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
await logger.log({
|
||||
type: AuditEventType.AUTH_LOGIN,
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: `u-${i}` },
|
||||
});
|
||||
}
|
||||
|
||||
const results = await logger.query({ limit });
|
||||
return results.length <= limit;
|
||||
}
|
||||
),
|
||||
{ numRuns: 20 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
32
packages/observability/monitor/audit-logging/vite.config.ts
Normal file
32
packages/observability/monitor/audit-logging/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/audit-logging",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/crypto",
|
||||
"@suaveplan/crypto-hash",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/serialize",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"src/constants/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
67
packages/observability/monitor/health-check/README.md
Normal file
67
packages/observability/monitor/health-check/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# @suaveplan/health-check - Server-tier standardised /health, /ready, /live endpoints with dependency checks — HealthChecker/ResponseBuilder/HttpCheck/TcpCheck with injectable Clock+TimersLike+LoggerLike+fetch, HEALTH_CHECK_CODES error registry, and OTel self-telemetry.
|
||||
|
||||
Server-tier health-check core for `/health`, `/ready`, `/live` endpoints with
|
||||
dependency checks (HTTP, TCP, custom), aggregated status, caching, and
|
||||
typed lifecycle events.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid…
|
||||
- [health-checker](./src/health-checker/health-checker.md) — `HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs t…
|
||||
- [health-checker.helpers](./src/health-checker/health-checker.helpers.md) — `health-checker.helpers` carries the pure helpers that back the `HealthChecker` class: the Zod schemas that validate co…
|
||||
- [health-checker](./src/health-checker/health-checker.md) — `HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs t…
|
||||
- [http-check](./src/http-check/http-check.md) — `createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appea…
|
||||
- [http-check](./src/http-check/http-check.md) — `createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appea…
|
||||
- [response-builder](./src/response-builder/response-builder.md) — `ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express…
|
||||
- [response-builder](./src/response-builder/response-builder.md) — `ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express…
|
||||
- [tcp-check](./src/tcp-check/tcp-check.md) — `createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once…
|
||||
- [tcp-check](./src/tcp-check/tcp-check.md) — `createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instrume…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instrume…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`health-checker/`](./src/health-checker/health-checker.md) — `HealthChecker`: registry, runner, aggregator, `EventBus`.
|
||||
- [`http-check/`](./src/http-check/http-check.md) — `createHttpCheck`: fetch-based probe with injectable `fetchImpl`.
|
||||
- [`tcp-check/`](./src/tcp-check/tcp-check.md) — `createTcpCheck`: socket probe with injectable `TcpConnectFn`.
|
||||
- [`response-builder/`](./src/response-builder/response-builder.md) — `ResponseBuilder`: maps statuses to HTTP codes.
|
||||
- [`error-codes/`](./src/error-codes/error-codes.md) — `HEALTH_CHECK_CODES` + `HealthCheckError`.
|
||||
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter (counter + histogram).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/health-check
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import {
|
||||
HealthChecker,
|
||||
ResponseBuilder,
|
||||
createHttpCheck,
|
||||
createTcpCheck,
|
||||
} from "@suaveplan/health-check";
|
||||
|
||||
const checker = new HealthChecker({ name: "api", version: "1.0.0" });
|
||||
checker.addCheck({
|
||||
name: "upstream",
|
||||
check: createHttpCheck({ url: "https://up.example.com/health" }),
|
||||
});
|
||||
checker.addCheck({
|
||||
name: "postgres",
|
||||
check: createTcpCheck({ host: "db.internal", port: 5432 }),
|
||||
});
|
||||
|
||||
const builder = new ResponseBuilder(checker);
|
||||
const { statusCode, body } = await builder.health();
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
101
packages/observability/monitor/health-check/package.json
Normal file
101
packages/observability/monitor/health-check/package.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "@suaveplan/health-check",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Server-tier standardised /health, /ready, /live endpoints with dependency checks — HealthChecker/ResponseBuilder/HttpCheck/TcpCheck with injectable Clock+TimersLike+LoggerLike+fetch, HEALTH_CHECK_CODES error registry, and OTel self-telemetry.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./health-checker": {
|
||||
"import": {
|
||||
"types": "./dist/health-checker/index.d.ts",
|
||||
"default": "./dist/health-checker/index.js"
|
||||
}
|
||||
},
|
||||
"./http-check": {
|
||||
"import": {
|
||||
"types": "./dist/http-check/index.d.ts",
|
||||
"default": "./dist/http-check/index.js"
|
||||
}
|
||||
},
|
||||
"./tcp-check": {
|
||||
"import": {
|
||||
"types": "./dist/tcp-check/index.d.ts",
|
||||
"default": "./dist/tcp-check/index.js"
|
||||
}
|
||||
},
|
||||
"./response-builder": {
|
||||
"import": {
|
||||
"types": "./dist/response-builder/index.d.ts",
|
||||
"default": "./dist/response-builder/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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/logger": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "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",
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# `error-codes/error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes` provides the canonical error namespace for `@suaveplan/health-check`. Every public failure path — invalid options, duplicate registration, unknown look-ups, per-check timeouts, and unexpected check-function throws — surfaces as a `HealthCheckError` whose `code` is drawn from the `HEALTH_CHECK_CODES` registry. Consumers SHOULD discriminate on `error.code` rather than `instanceof` so the namespace survives module-boundary copies and JSON serialisation.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable namespaced codes built via `@suaveplan/error`'s `defineCodes("HEALTH_CHECK", …)`.
|
||||
- Typed `HealthCheckCode` literal union narrows `switch` blocks at compile time.
|
||||
- `HealthCheckError` extends `SuaveplanError<HealthCheckCode>` with a **public** constructor that delegates to the protected base, so call-site `new HealthCheckError(...)` works.
|
||||
- `cause` + `context` carried through the inherited options object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
HEALTH_CHECK_CODES,
|
||||
HealthCheckError,
|
||||
} from "@suaveplan/health-check";
|
||||
|
||||
throw new HealthCheckError(
|
||||
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
|
||||
"check \"db\" is not registered",
|
||||
{ context: { name: "db" } },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
await checker.runCheck("db");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof HealthCheckError &&
|
||||
err.code === HEALTH_CHECK_CODES.CHECK_TIMEOUT
|
||||
) {
|
||||
metrics.increment("health.timeout", { ctx: err.context });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HEALTH_CHECK_CODES` — frozen map: `INVALID_OPTIONS`, `DUPLICATE_CHECK`, `UNKNOWN_CHECK`, `CHECK_TIMEOUT`, `CHECK_EXECUTION_FAILED`.
|
||||
- `HealthCheckCode` — the literal-union type derived from the registry values.
|
||||
- `HealthCheckError(code, message, options?)` — `options` carries `cause` and `context`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` namespaces every leaf under `HEALTH_CHECK.` and freezes the registry to prevent accidental mutation. `HealthCheckError`'s public constructor exists only because `SuaveplanError` declares its constructor `protected` — without an explicit override the call-site `new HealthCheckError(...)` would not type-check. The constructor sets `this.name = "HealthCheckError"` so logs / traces show a meaningful class name even after structured clone.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HEALTH_CHECK_CODES, HealthCheckError } from "./error-codes.js";
|
||||
|
||||
describe("HEALTH_CHECK_CODES", () => {
|
||||
it("namespaces every code under HEALTH_CHECK.", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(HEALTH_CHECK_CODES)) {
|
||||
expect(value.startsWith("HEALTH_CHECK.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the expected leaf names", { timeout: 5000 }, () => {
|
||||
expect(HEALTH_CHECK_CODES.INVALID_OPTIONS).toBe(
|
||||
"HEALTH_CHECK.INVALID_OPTIONS"
|
||||
);
|
||||
expect(HEALTH_CHECK_CODES.DUPLICATE_CHECK).toBe(
|
||||
"HEALTH_CHECK.DUPLICATE_CHECK"
|
||||
);
|
||||
expect(HEALTH_CHECK_CODES.UNKNOWN_CHECK).toBe(
|
||||
"HEALTH_CHECK.UNKNOWN_CHECK"
|
||||
);
|
||||
expect(HEALTH_CHECK_CODES.CHECK_TIMEOUT).toBe(
|
||||
"HEALTH_CHECK.CHECK_TIMEOUT"
|
||||
);
|
||||
expect(HEALTH_CHECK_CODES.CHECK_EXECUTION_FAILED).toBe(
|
||||
"HEALTH_CHECK.CHECK_EXECUTION_FAILED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HealthCheckError", () => {
|
||||
it("carries the supplied code, message, cause, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new HealthCheckError(
|
||||
HEALTH_CHECK_CODES.UNKNOWN_CHECK,
|
||||
"unknown check",
|
||||
{ cause, context: { name: "db" } }
|
||||
);
|
||||
expect(err.code).toBe(HEALTH_CHECK_CODES.UNKNOWN_CHECK);
|
||||
expect(err.message).toBe("unknown check");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ name: "db" });
|
||||
expect(err.name).toBe("HealthCheckError");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("works without options", { timeout: 5000 }, () => {
|
||||
const err = new HealthCheckError(
|
||||
HEALTH_CHECK_CODES.INVALID_OPTIONS,
|
||||
"bad opts"
|
||||
);
|
||||
expect(err.context).toBeUndefined();
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/health-check`.
|
||||
*
|
||||
* Codes follow `HEALTH_CHECK.<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 (`INVALID_OPTIONS`),
|
||||
* registration / lookup failures on the `HealthChecker`
|
||||
* (`DUPLICATE_CHECK`, `UNKNOWN_CHECK`), per-check timeout enforcement
|
||||
* (`CHECK_TIMEOUT`), and unexpected check-function throws
|
||||
* (`CHECK_EXECUTION_FAILED`).
|
||||
*/
|
||||
export const HEALTH_CHECK_CODES = defineCodes("HEALTH_CHECK", [
|
||||
"INVALID_OPTIONS",
|
||||
"DUPLICATE_CHECK",
|
||||
"UNKNOWN_CHECK",
|
||||
"CHECK_TIMEOUT",
|
||||
"CHECK_EXECUTION_FAILED",
|
||||
] as const);
|
||||
|
||||
export type HealthCheckCode =
|
||||
(typeof HEALTH_CHECK_CODES)[keyof typeof HEALTH_CHECK_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for every `@suaveplan/health-check` failure path.
|
||||
*
|
||||
* Extends `SuaveplanError<HealthCheckCode>` 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 HealthCheckError(...)` works at the call site —
|
||||
* `SuaveplanError`'s own constructor is protected.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { HEALTH_CHECK_CODES, HealthCheckError } from "@suaveplan/health-check";
|
||||
*
|
||||
* try {
|
||||
* checker.addCheck({ name: "db", check });
|
||||
* } catch (err) {
|
||||
* if (err instanceof HealthCheckError && err.code === HEALTH_CHECK_CODES.DUPLICATE_CHECK) {
|
||||
* console.error("already registered", err.context);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class HealthCheckError extends SuaveplanError<HealthCheckCode> {
|
||||
public constructor(
|
||||
code: HealthCheckCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "HealthCheckError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HealthCheckCode } from "./error-codes.js";
|
||||
export { HEALTH_CHECK_CODES, HealthCheckError } from "./error-codes.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user