first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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 -->

View File

@@ -0,0 +1 @@
Logging, health checks, audit logging, and analytics instrumentation.

View File

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

View File

@@ -0,0 +1,53 @@
# `consent.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `ConsentPolicy` SPI bundled with `@suaveplan/analytics-browser`. The tracker calls `policy.isAllowed(event)` before any sampling, scrubbing, or queueing decision, so a `false` return short-circuits the entire pipeline and emits a `drop` event with reason `consent`. Two built-in policies ship with the package: `AllowAllPolicy` (default) and `DoNotTrackPolicy` (honours `navigator.doNotTrack === "1"`). Downstream consumers can implement their own policy — e.g. one that consults a stored cookie-banner preference — by implementing the interface from `@suaveplan/analytics-browser/types`.
## Features
- Lazy navigator lookup — both policies are constructable in SSR / Node contexts where `navigator` is undefined; the policy simply permits the event in that case.
- Zero state — both built-ins are stateless and reentrant.
- Strict equality on `"1"` — matches the W3C Tracking Preference Expression spec; values `"0"` or `"unspecified"` permit tracking.
## Usage: Basic
```ts
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
const tracker = new AnalyticsTracker(transport, {
consentPolicy: new DoNotTrackPolicy(),
});
tracker.track("page_loaded"); // dropped if navigator.doNotTrack === "1"
```
## Usage: Advanced
```ts
import type {
AnalyticsEvent,
ConsentPolicy,
} from "@suaveplan/analytics-browser/types";
class CookieBannerPolicy implements ConsentPolicy {
public constructor(private readonly read: () => "granted" | "denied") {}
public isAllowed(_event: AnalyticsEvent): boolean {
return this.read() === "granted";
}
}
```
## API Reference
- `class AllowAllPolicy implements ConsentPolicy` — `isAllowed(event) → true`.
- `class DoNotTrackPolicy implements ConsentPolicy` — returns `false` iff `typeof navigator !== "undefined" && navigator.doNotTrack === "1"`.
## Implementation Notes
`DoNotTrackPolicy` reads `navigator.doNotTrack` on every call rather than caching at construction time. This is deliberate: the spec lets the user toggle the preference at runtime in some browsers (notably in private windows), and caching would freeze the decision at construction. The cost is a single property read per event — negligible compared with the JSON serialisation that follows on the happy path. Both classes pass their `event` argument with a leading underscore name to signal it is intentionally unused; lint rules permit underscore-prefixed unused parameters.

View File

@@ -0,0 +1,82 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed error class for `@suaveplan/analytics-browser`. The registry uses `@suaveplan/error`'s `defineCodes` so every value is namespaced under the `ANALYTICS_BROWSER.` prefix and resolves through the shared error-resolution pipeline (i18n, OTel span events, structured JSON). Consumers SHOULD discriminate on `error.code` rather than `instanceof` checks so the namespace survives module boundaries and runtime serialisation. The `AnalyticsBrowserError` class is the only typed error thrown by the package and extends `SuaveplanError<AnalyticsBrowserCode>` so a `switch (err.code)` block narrows against the literal union.
## Features
- Eight canonical reasons covering Zod-boundary validation, transport timeout / failure, durable queue overflow, missing host storage, tracker-after-shutdown rejection, invalid analytics-event payload, and beacon rejection.
- Code values are TypeScript `as const` literals so the resulting union is fully inferred.
- `AnalyticsBrowserError` exposes a public constructor (the base `SuaveplanError` constructor is protected) carrying `cause` and `context` for full forensic detail.
## Usage: Basic
```ts
import {
ANALYTICS_BROWSER_CODES,
AnalyticsBrowserError,
} from "@suaveplan/analytics-browser/error-codes";
try {
await transport.send(events);
} catch (err) {
if (
err instanceof AnalyticsBrowserError &&
err.code === ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT
) {
console.warn("retry on next flush", err.context);
}
}
```
## Usage: Advanced
```ts
import {
ANALYTICS_BROWSER_CODES,
type AnalyticsBrowserCode,
AnalyticsBrowserError,
} from "@suaveplan/analytics-browser/error-codes";
function explain(code: AnalyticsBrowserCode): string {
switch (code) {
case ANALYTICS_BROWSER_CODES.TRANSPORT_TIMEOUT:
return "request exceeded timeoutMs";
case ANALYTICS_BROWSER_CODES.TRANSPORT_FAILED:
return "backend returned 4xx";
case ANALYTICS_BROWSER_CODES.QUEUE_FULL:
return "durable queue is at capacity";
case ANALYTICS_BROWSER_CODES.STORAGE_UNAVAILABLE:
return "host Storage is missing";
case ANALYTICS_BROWSER_CODES.SHUTDOWN:
return "tracker has been shut down";
case ANALYTICS_BROWSER_CODES.INVALID_EVENT:
return "event failed Zod validation";
case ANALYTICS_BROWSER_CODES.INVALID_OPTIONS:
return "constructor options failed validation";
case ANALYTICS_BROWSER_CODES.BEACON_REJECTED:
return "navigator.sendBeacon returned false";
}
}
throw new AnalyticsBrowserError(
ANALYTICS_BROWSER_CODES.QUEUE_FULL,
"watermark exceeded",
{ context: { depth: 1024 } },
);
```
## API Reference
- `ANALYTICS_BROWSER_CODES` — `Readonly<Record<Reason, "ANALYTICS_BROWSER.<Reason>">>`. The keys are the literal reason names; the values are namespaced strings.
- `type AnalyticsBrowserCode` — string-literal union of all eight code values.
- `class AnalyticsBrowserError extends SuaveplanError<AnalyticsBrowserCode>` — `new AnalyticsBrowserError(code, message, { cause?, context? })`.
## Implementation Notes
`defineCodes` is called eagerly at module import so the registry is populated by the time any tracker or transport asks `@suaveplan/error` to resolve a code. The namespace string `ANALYTICS_BROWSER` is unique across the project registry — no other package owns it. Adding a new code is a backward-compatible change as long as the existing reasons remain stable; deletions or renames require an spec change.

View File

@@ -0,0 +1,51 @@
# `id.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal cryptographically-random ID generator shared by the analytics tracker, the session manager, and any consumer that injects a default `IdGenerator`. Centralising the implementation removes the duplicate `generateId()` that lived in both `tracker.ts` and `session.ts` upstream and guarantees that anonymous IDs, event IDs, and session IDs share a single entropy source. The output is a 32-character hex string drawn from `crypto.getRandomValues`, suitable for use as a public identifier without leaking host state.
## Features
- Single source of randomness — `globalThis.crypto.getRandomValues` directly, no Math.random fallback.
- Pure synchronous return; no `Promise` overhead on the hot path.
- Hex-encoded so the ID is URL-safe, log-safe, and 1:1 with the byte length (16 bytes → 32 chars).
- Re-exported through `src/internal/id/index.ts` so the rest of the package never imports from a flat module.
## Usage: Basic
```ts
import { generateId } from "@suaveplan/analytics-browser/tracker";
// ↑ generateId is internal; in tests / docs import from the barrel that
// happens to re-export it. Production code typically lets the tracker
// default-construct its own IDs.
const anonymousId = generateId();
```
## Usage: Advanced
```ts
import type { IdGenerator } from "@suaveplan/types";
import { generateId } from "./internal/id/id.js";
// Wrap the function-shape IdGenerator from @suaveplan/types around the
// shared implementation — useful when a downstream consumer wants to
// inject a deterministic counter in tests.
const realIds: IdGenerator = generateId;
const fakeIds: IdGenerator = (() => {
let n = 0;
return () => `evt-${(++n).toString().padStart(32, "0")}`;
})();
```
## API Reference
- `generateId(): string` — returns a fresh 32-character lowercase hex string. Throws synchronously only if `globalThis.crypto.getRandomValues` is missing or throws (e.g. when the host crypto API is intentionally disabled).
## Implementation Notes
The 16-byte entropy budget gives 128 bits — the same size as a UUIDv4 — without the dash separators or version nibbles. Hex encoding is the simplest reversible representation and is cheap (~16 string concatenations) compared with base32 or base64. The byte length lives in `constants/index.ts` so a future opt-in to 24-byte IDs is a one-line constant change rather than a refactor across call sites.

View File

@@ -0,0 +1,62 @@
# `pii.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements field-level PII scrubbing for `@suaveplan/analytics-browser`. `scrubPII(event, rules)` takes an `AnalyticsEvent` plus an ordered array of `ScrubRule`s and returns a new event with the affected `properties` either redacted, replaced with a deterministic fingerprint, or dropped entirely. Scrubbing runs on the tracker hot path between consent / sampling decisions and the queue write, so it must be synchronous, side-effect-free, and never throw on valid input. The function clones `properties` via `structuredClone` so the original event remains immutable.
## Features
- Three strategies: `"redact"` (replace value with `"[REDACTED]"`), `"hash"` (replace with FNV-1a fingerprint), `"drop"` (delete the key).
- Dotted-path field resolution — `"user.email"` walks into nested objects.
- Order-preserving — rules apply in the order given so later rules see the effect of earlier ones.
- Pure / synchronous — no async, no I/O, no `subtle.digest`.
- Idempotent — the same input + rules always yields the same output (including the fingerprint).
## Usage: Basic
```ts
import { scrubPII } from "@suaveplan/analytics-browser/pii";
const safe = scrubPII(event, [
{ field: "email", strategy: "redact" },
{ field: "ssn", strategy: "drop" },
]);
```
## Usage: Advanced
```ts
import type { ScrubRule } from "@suaveplan/analytics-browser/types";
import { scrubPII } from "@suaveplan/analytics-browser/pii";
const piiRules: readonly ScrubRule[] = [
{ field: "user.email", strategy: "redact" },
{ field: "user.phone", strategy: "hash" },
{ field: "payment.cardNumber", strategy: "drop" },
];
function safeTrack(name: string, properties: Record<string, unknown>) {
const wrapped = {
id: crypto.randomUUID(),
name,
properties,
timestamp: new Date().toISOString(),
sessionId: "s",
anonymousId: "a",
};
return scrubPII(wrapped, piiRules);
}
```
## API Reference
- `scrubPII(event: AnalyticsEvent, rules: readonly ScrubRule[]): AnalyticsEvent` — returns a fresh event with scrubbed `properties`. Returns the original event reference when `rules.length === 0`.
- Rule shape: `{ field: string; strategy: "redact" | "hash" | "drop" }`.
## Implementation Notes
The fingerprint is FNV-1a 32-bit, encoded as 8 lowercase hex chars. FNV-1a was chosen over Web Crypto's `subtle.digest` because the latter is asynchronous, which would force the entire scrub pipeline onto a Promise chain and complicate the tracker's hot path. FNV-1a is NOT a cryptographic hash — it is a redaction that preserves cross-event correlation (the same value always fingerprints to the same hex string) without revealing the value itself. If a stronger guarantee is required, downstream code SHOULD scrub via a custom rule sequence and a pre-hashed input value, or implement its own `Sampler`-style hook.

View File

@@ -0,0 +1,56 @@
# `sampling.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements head-based sampling for `@suaveplan/analytics-browser`. The tracker calls `sampler.shouldSample(event)` after the consent gate but before the scrubber so events that will be dropped never pay the cost of property scrubbing or queue persistence. `HeadSampler` accepts a base sample rate in `[0, 1]` plus an optional `Record<eventName, rate>` override map so common events (e.g. `page`) can be captured at a different rate than rare events (e.g. `error_reported`).
## Features
- Per-event-name overrides — `new HeadSampler(0.1, { error_reported: 1 })` keeps every error while sampling everything else at 10 %.
- Strict clamping — rates `<= 0` always drop, rates `>= 1` always keep; `NaN` clamps to 0.
- Injectable random source — the constructor accepts a `random: () => number` seam so tests can drive deterministic decisions without monkey-patching `Math.random`.
- Stateless — every call is independent; safe to share a sampler across many trackers.
## Usage: Basic
```ts
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
const sampler = new HeadSampler(0.25); // keep 25 % of events
```
## Usage: Advanced
```ts
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
// 1 % sampling for verbose page-view events, 100 % for clicks and errors.
const sampler = new HeadSampler(0.01, {
clicked: 1,
error_reported: 1,
});
// In tests, inject a deterministic counter so behaviour is reproducible.
let n = 0;
const deterministic = new HeadSampler(0.5, {}, () => {
const sequence = [0.4, 0.6, 0.2, 0.8];
return sequence[n++ % sequence.length] as number;
});
```
## API Reference
- `class HeadSampler implements Sampler`
- `new HeadSampler(rate, overrides?, random?)`
- `rate: number` — base rate in `[0, 1]`. Values outside this range clamp.
- `overrides?: Readonly<Record<string, number>>` — per-event-name rate. Each entry clamps independently.
- `random?: () => number` — random source; defaults to `Math.random`.
- `shouldSample(event): boolean` — `true` keeps the event, `false` drops it.
## Implementation Notes
The clamp logic guards against floating-point drift and pathological caller input (e.g. accidentally passing `rate=2`). NaN clamps to 0 rather than 1 so a misconfigured environment variable does not silently flood the backend. The injected random source enables deterministic testing without resorting to `vi.stubGlobal` (banned by §2 of `AGENTS.md`).

View File

@@ -0,0 +1,52 @@
# `analytics-event-schema.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical Zod schema for `AnalyticsEvent` and a `validateAnalyticsEvent` parse helper. The schema lets consumers validate untrusted payloads (iframe postMessage bridges, browser extension messages, recovered IndexedDB rows) before handing them to the tracker. The helper wraps `safeParse` so the boundary failure mode is a typed `AnalyticsBrowserError(INVALID_EVENT)` carrying the full Zod issue list on `error.context.issues` — no raw `ZodError` leaks past the boundary.
## Features
- Single source of truth for the on-wire and in-memory shape of an analytics event.
- Required: `id`, `name`, `timestamp`, `sessionId`, `anonymousId`, `properties` (record). Optional: `userId`.
- Boundary-failure translation: every Zod issue is collected into `error.context.issues` so observability tooling can render the full failure tree.
- Schema is exported directly so downstream packages can `.merge` / `.extend` to add domain-specific shape (e.g. an e-commerce analytics extension).
## Usage: Basic
```ts
import { validateAnalyticsEvent } from "@suaveplan/analytics-browser/schema";
const safe = validateAnalyticsEvent(unknownPayload);
tracker.track(safe.name, safe.properties);
```
## Usage: Advanced
```ts
import z from "zod";
import { AnalyticsEventSchema } from "@suaveplan/analytics-browser/schema";
// Domain-specific extension — every commerce event additionally carries
// a non-empty `cartId`. Composition stays within the Zod model so the
// .issues path stays intact when validation fails.
const CommerceEventSchema = AnalyticsEventSchema.extend({
properties: z
.record(z.string(), z.unknown())
.refine((p) => typeof p.cartId === "string" && p.cartId.length > 0, {
message: "missing cartId",
}),
});
```
## API Reference
- `AnalyticsEventSchema: z.ZodType<AnalyticsEvent>` — the raw Zod schema.
- `validateAnalyticsEvent(input: unknown): AnalyticsEvent` — parses and returns; throws `AnalyticsBrowserError(INVALID_EVENT)` on failure with `error.context.issues` exposing the Zod issue array.
## Implementation Notes
The schema deliberately uses `z.string().min(1)` rather than `z.string()` for every identifier so a producer cannot bypass validation by sending an empty string. `properties` uses `z.record(z.string(), z.unknown())` rather than a stricter shape because tracker callers attach arbitrary metadata; if a downstream consumer needs a stricter contract they SHOULD `.extend` the schema as shown above. The helper does not log on failure — that is the caller's decision — and never swallows errors silently.

View File

@@ -0,0 +1,65 @@
# `session.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks the lifecycle of a single browser session for `@suaveplan/analytics-browser`. The `SessionManager` owns the session ID, the start time, the page-view counter, the captured `document.referrer`, and the inactivity-timeout state machine. Consumers attach it to a tracker (`tracker.setSessionId(session.getSessionId())`) and call `recordPageView()` / `touch()` as user interaction occurs. The class is fully clock-injectable so tests can drive the inactivity timeout without sleeping.
## Features
- Cryptographically-random session ID (defaults to the package's shared `generateId`).
- Inactivity timeout — `isActive()` returns `false` after `timeoutMs` of silence; the next `recordPageView()` is a no-op until `renew()` is called.
- Manual `renew()` — resets the ID, start time, page-view count, and active flag (used on logout / login or app-managed session boundary events).
- Injectable `clock`, `idGenerator`, and `referrer` for deterministic tests and SSR.
## Usage: Basic
```ts
import { SessionManager } from "@suaveplan/analytics-browser/session";
const session = new SessionManager({ timeoutMs: 30 * 60 * 1000 });
session.recordPageView();
const info = session.getInfo();
```
## Usage: Advanced
```ts
import { SessionManager } from "@suaveplan/analytics-browser/session";
let now = 0;
const session = new SessionManager({
timeoutMs: 60_000,
clock: () => now,
idGenerator: () => "test-session",
referrer: "https://example.com",
});
now = 30_000;
session.recordPageView();
now = 120_000; // exceeds timeoutMs since last activity
session.isActive(); // → false
session.renew(); // mint a fresh session id
```
## API Reference
- `class SessionManager`
- `new SessionManager(options?)` — `options: SessionOptions { timeoutMs?, referrer?, clock?, idGenerator? }`
- `getSessionId(): string`
- `getReferrer(): string`
- `getPageViews(): number`
- `recordPageView(): void` — no-op when session is inactive
- `touch(): void` — resets the inactivity clock
- `getDuration(): number` — milliseconds since `startedAt`
- `isActive(): boolean` — auto-expires past `timeoutMs`
- `end(): void` — explicitly terminates the session
- `renew(): string` — mints a fresh session id, zeros page views, resets the inactivity clock
- `getInfo(): SessionInfo` — `{ id, startedAt, pageViews, duration, referrer, isActive }`
## Implementation Notes
`getDuration` returns the live duration when the session is active, otherwise the duration up to the last activity timestamp. This keeps the duration stable after `end()` is called — repeated `getDuration()` calls on an ended session return the same value rather than drifting upward with wall-clock time. The `document.referrer` lookup is guarded by `typeof document !== "undefined"` so SSR contexts default to `""` rather than throwing.

View File

@@ -0,0 +1,54 @@
# `storage.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `StorageBackend` SPI bundled with `@suaveplan/analytics-browser`. Two backends ship: `MemoryStorageBackend` (default) and `WebStorageBackend` (DOM `Storage`-backed JSON queue with an optional `maxBytes` watermark). The tracker writes every accepted event into both an in-memory FIFO and the configured backend; the backend supplies durability across page reloads or transport outages. An IndexedDB adapter will slot into the same SPI once `@suaveplan/storage-browser` lands.
## Features
- `MemoryStorageBackend` — zero-config FIFO in a single array.
- `WebStorageBackend` — `localStorage` / `sessionStorage` / any `Storage` implementation, JSON-encoded, custom key, byte-count watermark with `QUEUE_FULL` ejection.
- Defensive read — corrupted or non-array payloads are ignored rather than throwing.
- Constructor validation — `WebStorageBackend({ storage: null })` throws `STORAGE_UNAVAILABLE` at construction time, before the tracker ever runs.
## Usage: Basic
```ts
import {
MemoryStorageBackend,
WebStorageBackend,
} from "@suaveplan/analytics-browser/storage";
// SSR / tests
const backend = new MemoryStorageBackend();
// Production browser
const backend = new WebStorageBackend({ storage: localStorage });
```
## Usage: Advanced
```ts
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
// Tight watermark for low-storage devices.
const backend = new WebStorageBackend({
storage: localStorage,
key: "@my-app/analytics",
maxBytes: 64_000,
});
```
## API Reference
- `interface StorageBackend` — `enqueue(events) → Promise<void>`, `dequeueAll() → Promise<AnalyticsEvent[]>`, `clear() → Promise<void>`, `getDepth() → Promise<number>`.
- `class MemoryStorageBackend implements StorageBackend` — no constructor arguments.
- `class WebStorageBackend implements StorageBackend` — `new WebStorageBackend({ storage, key?, maxBytes? })`. Throws `AnalyticsBrowserError(STORAGE_UNAVAILABLE)` when `storage` is falsy and `AnalyticsBrowserError(QUEUE_FULL)` on enqueue overflow.
## Implementation Notes
`WebStorageBackend` writes the full JSON array on every enqueue rather than appending — this keeps the on-disk shape trivially reversible and means a corrupted last-write fails-closed (the parse returns `[]`, the next flush drains nothing). The cost is an O(n) write per enqueue; for typical analytics traffic (10–100 events per flush) this is negligible. The byte-count check is performed against the encoded payload length rather than the array length so a single oversized event cannot bypass the watermark by dragging in many other events.

View File

@@ -0,0 +1,58 @@
# `telemetry.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the OpenTelemetry instruments emitted by `@suaveplan/analytics-browser`. Three counters cover the accept / flush / drop paths and two histograms cover batch sizes and flush latencies. Every instrument is bound lazily through `metrics.getMeter(METER_NAME)` so production code that never installs a `MeterProvider` pays only a single property-read cost per recorded event — the OTel no-op meter discards everything cheaply.
## Features
- `eventsTrackedCounter{name}` — accepted events handed to the queue.
- `eventsFlushedCounter{status}` — flush attempts; `status: "success" | "failure"`.
- `eventsDroppedCounter{reason}` — dropped events; `reason: "consent" | "sampling" | "queue_full" | "shutdown" | "invalid"`.
- `batchSizeHistogram` — observed flush sizes (event count).
- `flushLatencyHistogram` — wall-clock duration of a single `transport.send`, in milliseconds.
- Helper `record*` functions encapsulate the tag schema so call sites never assemble tag bags inline.
## Usage: Basic
```ts
import { recordEventTracked } from "@suaveplan/analytics-browser/telemetry";
recordEventTracked("clicked");
```
## Usage: Advanced
```ts
import {
eventsTrackedCounter,
recordFlush,
} from "@suaveplan/analytics-browser/telemetry";
// Add custom tags to the underlying instrument when integrating with a
// product-specific OTel pipeline.
eventsTrackedCounter.add(1, {
name: "clicked",
surface: "checkout-button",
});
recordFlush("success", batch.length, performance.now() - start);
```
## API Reference
- `eventsTrackedCounter: Counter` — backing instrument.
- `eventsFlushedCounter: Counter`
- `eventsDroppedCounter: Counter`
- `batchSizeHistogram: Histogram`
- `flushLatencyHistogram: Histogram`
- `recordEventTracked(name: string): void`
- `recordFlush(status, eventCount, latencyMs): void`
- `recordDropped(reason: DropReason): void`
## Implementation Notes
The meter name follows the convention of `@suaveplan/<package>` so dashboards and alerts can filter on a single attribute. Histograms intentionally do not declare explicit bucket boundaries — the SDK picks reasonable defaults, and downstream observability platforms (Honeycomb / Grafana / Datadog) typically re-bucket on ingest anyway. The `batch_size` histogram has no unit; the `flush_latency_ms` histogram declares `ms`.

View File

@@ -0,0 +1,95 @@
# `tracker.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AnalyticsTracker` is the central orchestrator of `@suaveplan/analytics-browser`. It accepts user-facing events through `track()` / `page()` / `identify()`, runs each through a deterministic consent → sampling → PII-scrub pipeline, persists accepted events into both an in-memory FIFO and an injectable `StorageBackend`, and flushes batched events through the supplied `Transport`. Lifecycle observability is exposed through a typed `EventBus<AnalyticsEventMap>` on `tracker.events`. Every browser-API touch (timers, clock, IDs, storage) is reached through an injectable seam, so the tracker is fully testable without `vi.stubGlobal` or browser-specific mocks.
## Features
- Six-channel typed lifecycle bus — `track`, `pageview`, `identify`, `flush`, `drop`, `error`, `shutdown`.
- Single-flight `flush()` — concurrent calls share the in-flight promise.
- Auto-flush via injectable `TimersLike` — `flushInterval=0` disables the interval entirely.
- Durable queue round-trip — persisted events are restored via `restore()` and cleared after a successful flush.
- OTel telemetry — every accept / drop / flush emits the bundled counters/histograms.
- Single-payload event bus — payload shape `{ event }` / `{ reason, event? }` etc. for ergonomic destructuring.
## Usage: Basic
```ts
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
const tracker = new AnalyticsTracker(
new BatchTransport({ endpoint: "/api/analytics" }),
);
tracker.identify("user-123");
tracker.track("clicked", { surface: "header" });
```
## Usage: Advanced
```ts
import { AnalyticsTracker } from "@suaveplan/analytics-browser/tracker";
import { BatchTransport, BeaconTransport } from "@suaveplan/analytics-browser/transport";
import { DoNotTrackPolicy } from "@suaveplan/analytics-browser/consent";
import { HeadSampler } from "@suaveplan/analytics-browser/sampling";
import { WebStorageBackend } from "@suaveplan/analytics-browser/storage";
const transport = new BatchTransport({ endpoint: "/api/analytics" });
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
const tracker = new AnalyticsTracker(transport, {
consentPolicy: new DoNotTrackPolicy(),
sampler: new HeadSampler(0.1, { error_reported: 1 }),
scrubRules: [
{ field: "email", strategy: "redact" },
{ field: "card.number", strategy: "drop" },
],
storage: new WebStorageBackend({ storage: localStorage }),
});
// Restore any events persisted across reload.
await tracker.restore();
// Unload-safe flush: hand off to the beacon transport.
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
void beacon.send([...tracker.events.constructor.prototype, ...[]]);
void tracker.flush();
}
});
tracker.events.on("flush", ({ eventCount, success, latencyMs }) => {
console.info("flush", { eventCount, success, latencyMs });
});
tracker.events.on("drop", ({ reason, event }) => {
console.warn("dropped", reason, event?.name);
});
// Final shutdown — drain and disable.
await tracker.shutdown();
```
## API Reference
- `class AnalyticsTracker`
- `new AnalyticsTracker(transport: Transport, options?: TrackerOptions)`.
- `events: EventBus<AnalyticsEventMap>`.
- `restore(): Promise<void>` — replay persisted events into the in-memory queue.
- `track(name, properties?)`.
- `page(url?, properties?)`.
- `identify(userId, traits?)`.
- `setProperties(properties)`.
- `getAnonymousId() / getUserId() / getSessionId() / setSessionId(id) / getQueueSize()`.
- `flush(): Promise<boolean>` — single-flight; success drains storage too.
- `shutdown(): Promise<void>` — idempotent.
## Implementation Notes
The tracker preserves event order through `flush` failure paths by `unshift`-ing the drained batch back onto the head of the in-memory queue when the transport returns `false` or throws. This keeps the relative ordering of new events that arrived during the flush window. The persistence layer is written-through (every accepted event hits storage), but cleared only on successful flush — a transport that returns `false` leaves the persisted copy intact so a subsequent flush attempt or page reload can still recover the events. The `flushInFlight` promise guard guarantees single-flight semantics; concurrent callers all observe the same outcome.

View File

@@ -0,0 +1,51 @@
# `batch.ts`
## Purpose
Implements `BatchTransport` — the HTTP transport used by `AnalyticsTracker` for the steady-state, non-unload flush path. Every call posts a JSON-encoded `{ events }` body to the configured endpoint, wrapped in an `AbortController` whose timeout is armed via the injected `TimersLike`. The transport short-circuits on `4xx` responses (no retry), retries with exponential backoff on `5xx` / network errors, and translates any post-abort failure into a typed `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` so callers can discriminate timeouts from other transport failures.
## Features
- `AbortController` + `timeoutMs` on every fetch — guaranteed bounded latency.
- `4xx` short-circuit — no retry storm on stable client errors.
- Exponential backoff on `5xx` / network — `retryDelay * 2 ** attempt`.
- Injectable `fetchFn` and `timers` — every browser-API touch is reachable from tests without DOM globals.
- Idempotent `shutdown()` — future `send()` calls are no-ops returning `true`.
## Usage: Basic
```ts
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
const transport = new BatchTransport({ endpoint: "/api/analytics" });
await transport.send(events);
```
## Usage: Advanced
```ts
import { BatchTransport } from "@suaveplan/analytics-browser/transport";
const transport = new BatchTransport({
endpoint: "https://collector.example.com/v1/events",
maxRetries: 5,
retryDelay: 500,
timeoutMs: 5_000,
fetchFn: async (url, init) => {
const req = new Request(url, init);
return fetch(req, { credentials: "include" });
},
});
```
## API Reference
- `class BatchTransport implements Transport`
- `new BatchTransport(options)` — `options: BatchTransportOptions { endpoint, maxRetries?, retryDelay?, timeoutMs?, fetchFn?, timers? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
- `send(events) → Promise<boolean>` — `true` on success, `false` on `4xx` or exhausted retries. Throws `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` when the fetch is aborted by the timeout.
- `shutdown() → Promise<void>` — disables further sends.
- `isActive() → boolean`.
## Implementation Notes
The timeout `setTimer` callback aborts the controller with an `AnalyticsBrowserError(TRANSPORT_TIMEOUT)` as the reason. If the host's `fetch` rejects with that exact error, the transport rethrows it as-is. If the fetch rejects with a different error after the signal has aborted (e.g. a `DOMException` synthesised by the runtime), the transport still maps the failure to `TRANSPORT_TIMEOUT` because the signal's aborted state is the authoritative reason. Otherwise the failure is treated as a generic retryable network error and the loop continues until `maxRetries` is exhausted.

View File

@@ -0,0 +1,50 @@
# `beacon.ts`
## Purpose
Implements `BeaconTransport` — the page-unload-safe transport used by `AnalyticsTracker.shutdown()` and any consumer wiring a `visibilitychange → hidden` flush. Browsers guarantee that `navigator.sendBeacon` will complete the request even during unload, which makes it the only reliable way to ship a last batch before navigation. When `sendBeacon` returns `false` (body too large, host rejected the beacon, navigator unavailable), the transport optionally falls back to `fetch` with `keepalive: true` or throws a typed `AnalyticsBrowserError(BEACON_REJECTED)`.
## Features
- `navigator.sendBeacon` first, `fetch keepalive` fallback.
- Injectable `sendBeaconFn` and `fetchFn` so happy-dom and Node test envs can drive both paths.
- Graceful degradation — missing `navigator` or missing `sendBeacon` returns `false`, letting the fallback or the typed error path take over.
- `fallbackToFetch` defaults to `true` so the steady-state behaviour matches what production code wants.
## Usage: Basic
```ts
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
const beacon = new BeaconTransport({ endpoint: "/api/analytics" });
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
void tracker.flush();
}
});
```
## Usage: Advanced
```ts
import { BeaconTransport } from "@suaveplan/analytics-browser/transport";
const beacon = new BeaconTransport({
endpoint: "/api/analytics",
fallbackToFetch: false, // strict — surface BEACON_REJECTED to the caller
sendBeaconFn: (url, body) => navigator.sendBeacon(url, body),
});
```
## API Reference
- `class BeaconTransport implements Transport`
- `new BeaconTransport(options)` — `options: BeaconTransportOptions { endpoint, fallbackToFetch?, sendBeaconFn?, fetchFn? }`. Throws `AnalyticsBrowserError(INVALID_OPTIONS)` when `endpoint` is empty.
- `send(events) → Promise<boolean>` — `true` on beacon success or `200` fallback fetch. Throws `AnalyticsBrowserError(BEACON_REJECTED)` when `sendBeaconFn` returns `false` and `fallbackToFetch === false`.
- `shutdown() → Promise<void>` — disables future sends.
- `isActive() → boolean`.
## Implementation Notes
`sendBeacon` is synchronous and returns a boolean that signals whether the request was queued, not whether the server received it. The transport treats `true` as success because the browser owns delivery from that point on — there is no observable response to inspect during unload. The fallback fetch sets `keepalive: true` so the same unload-safety guarantee applies; `keepalive` is supported in every browser that ships `sendBeacon`, which keeps the fallback meaningful.

View File

@@ -0,0 +1,64 @@
# `analytics.types.ts`
## Purpose
Single home for every type and interface consumed by the `@suaveplan/analytics-browser` runtime — the tracker, transports, session manager, storage backends, consent policies, samplers, scrubbers, and the typed `EventBus<AnalyticsEventMap>` lifecycle bus. Centralising types here lets feature modules import a single tree of contracts and lets downstream consumers (React shells, R3F apps) discover every public surface from one declaration file.
## Features
- `AnalyticsEvent` — the canonical event shape, fully immutable.
- `AnalyticsEventMap extends EventMap` — single-payload object on every channel so the `EventBus.emit(type, payload)` signature applies.
- `Transport` / `StorageBackend` / `ConsentPolicy` / `Sampler` — pluggable SPIs.
- `ScrubRule` / `ScrubStrategy` — PII scrubber contract.
- Option bags (`TrackerOptions`, `BatchTransportOptions`, `BeaconTransportOptions`, `SessionOptions`) — every field optional so call sites can opt into only what they need.
- Re-exports `Clock`, `IdGenerator`, `TimersLike` from `@suaveplan/types` so consumers don't need a second peer dependency.
## Usage: Basic
```ts
import type {
AnalyticsEvent,
AnalyticsEventMap,
TrackerOptions,
Transport,
} from "@suaveplan/analytics-browser/types";
```
## Usage: Advanced
```ts
import type { EventBus } from "@suaveplan/events";
import type {
AnalyticsEventMap,
DropReason,
StorageBackend,
} from "@suaveplan/analytics-browser/types";
declare function attach(
bus: EventBus<AnalyticsEventMap>,
backend: StorageBackend,
): void;
function classify(reason: DropReason): "operator" | "client" {
return reason === "queue_full" || reason === "shutdown"
? "operator"
: "client";
}
```
## API Reference
- `AnalyticsEvent` — id, name, properties, timestamp (ISO 8601), sessionId, anonymousId, optional userId.
- `DropReason` — `"consent" | "sampling" | "queue_full" | "shutdown" | "invalid"`.
- `AnalyticsEventMap extends EventMap` — `track`, `pageview`, `identify`, `flush`, `drop`, `error`, `shutdown` channels.
- `Transport` — `send(events) → Promise<boolean>`, `shutdown() → Promise<void>`.
- `StorageBackend` — `enqueue` / `dequeueAll` / `clear` / `getDepth`.
- `ConsentPolicy` — `isAllowed(event) → boolean`.
- `Sampler` — `shouldSample(event) → boolean`.
- `ScrubRule` — `{ field: string; strategy: ScrubStrategy }`.
- `SessionInfo` — id / startedAt / pageViews / duration / referrer / isActive.
- `SessionOptions`, `BatchTransportOptions`, `BeaconTransportOptions`, `TrackerOptions` — option bags described in their respective module docs.
## Implementation Notes
Every option field is `readonly` so option bags can be safely shared across instances. The types module is **zero-runtime** — TypeScript erases every export to `undefined` at build time, which keeps the package's effective entry chunk lean and lets dead-code elimination prune unused option bags. The `Clock`, `IdGenerator`, and `TimersLike` re-exports point at the `@suaveplan/types` package; consumers MAY import them directly from there if they already depend on `@suaveplan/types`.

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -0,0 +1,49 @@
# `health-checker/health-checker.helpers`
## Purpose
`health-checker.helpers` carries the pure helpers that back the `HealthChecker` class: the Zod schemas that validate constructor + per-check options, the default `Clock` / `TimersLike` pair, the aggregate-status function, the raw-result normaliser, and the timeout-enforcing wrapper. They are split out of `health-checker.ts` to keep the main class file inside the project line-count budget and to make each helper independently unit-testable through `HealthChecker`'s tests.
## Features
- `dependencyCheckSchema` + `optionsSchema` — Zod boundary schemas; throw `HealthCheckError(INVALID_OPTIONS)` on failure when wired in.
- `defaultClock` — `() => Date.now()`.
- `defaultTimers` — `setTimeout` / `clearTimeout` adapter for the `TimersLike` contract.
- `aggregateStatus(results, isCritical)` — pure function mapping a result list to overall `CheckStatus`.
- `normalizeResult(name, raw, elapsed, clock)` — pure normaliser preserving raw `name` / `timestamp` when present.
- `executeWithTimeout(thunk, ms, timers)` — schedules a `HealthCheckError(CHECK_TIMEOUT)` rejection through the supplied `TimersLike`.
## Usage: Basic
```ts
import { aggregateStatus } from "@suaveplan/health-check/health-checker";
const status = aggregateStatus(results, (name) => criticalNames.has(name));
```
## Usage: Advanced
```ts
import { executeWithTimeout } from "@suaveplan/health-check/health-checker";
const result = await executeWithTimeout(
() => probe(),
250,
{ setTimer: setTimeout, clearTimer: clearTimeout },
);
```
## API Reference
- `DEFAULT_TIMEOUT: 5000`
- `defaultClock: Clock`
- `defaultTimers: TimersLike`
- `dependencyCheckSchema: ZodObject`
- `optionsSchema: ZodObject`
- `aggregateStatus(results, isCritical) → CheckStatus`
- `normalizeResult(name, raw, elapsed, clock) → CheckResult`
- `executeWithTimeout<T>(thunk, ms, timers) → Promise<T>`
## Implementation Notes
`executeWithTimeout` carries a `settled` flag to absorb the race between the timer firing and the thunk resolving. Either path is responsible for flipping the flag; the other path no-ops. `clearTimer` is only called on the resolution / rejection side because the timer-fired side has already executed and there is nothing to cancel. Rejections from the thunk are coerced into `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error and can route on `code` instead of `instanceof Error`. The Zod schemas are constructed at module top level — instantiating them once per import keeps the per-call overhead to a single `safeParse` invocation.

View File

@@ -0,0 +1,65 @@
# `health-checker/health-checker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HealthChecker` is the orchestration core of `@suaveplan/health-check`. It owns a registry of dependency checks, runs them on demand, enforces per-check timeouts through an injected `TimersLike`, caches results for a configurable TTL, and produces standardised health, readiness, and liveness payloads suitable for the `/health`, `/ready`, and `/live` endpoints common to Kubernetes, ECS, Nomad, and reverse-proxy probes. Wall-clock readings come from the injected `Clock`, so tests can drive deterministic uptime + cache-expiry behaviour.
## Features
- Registry of `DependencyCheckConfig` with per-check `critical`, `timeout`, and `cacheTtlMs` overrides.
- Result aggregation that returns `healthy` / `degraded` / `unhealthy`: critical failures short-circuit, non-critical or degraded results downgrade to `degraded`.
- Typed `EventBus<HealthEventMap>` emitting `check:passed`, `check:failed`, and `status:changed` events.
- Zod-validated boundaries — every option object is parsed before use; failures throw `HealthCheckError` with `INVALID_OPTIONS`.
- OTel instrumentation: every check run records on `health_check_runs_total` + `health_check_duration_ms`, and every aggregate evaluation increments `health_check_aggregate_status_total`.
- Optional `LoggerLike` injection for `debug` registration logs and `warn` on failed checks.
## Usage: Basic
```ts
import { HealthChecker, createHttpCheck } 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" }),
});
const response = await checker.health();
```
## Usage: Advanced
```ts
import { HealthChecker, HealthEvent } from "@suaveplan/health-check";
const checker = new HealthChecker({
name: "api",
defaultTimeout: 2_000,
defaultCacheTtlMs: 5_000,
clock: () => performance.now() + epoch,
timers: fakeTimers,
logger,
});
checker.events.on(HealthEvent.STATUS_CHANGED, ({ previous, current }) => {
pagerduty.notify({ previous, current });
});
```
## API Reference
- `new HealthChecker(options)` — `options.name` required; `version`, `defaultTimeout`, `defaultCacheTtlMs`, `checks`, `clock`, `timers`, `logger` optional.
- `addCheck(config)` / `removeCheck(name)` / `getCheckNames()`.
- `runCheck(name)` → `CheckResult` (single check, honours cache).
- `health()` → `HealthResponse` (runs all, aggregates).
- `ready()` → `ReadinessResponse` (critical-only failures).
- `live()` → `LivenessResponse` (process is responding).
- `clearCache()` / `getUptimeMs()`.
- `events: EventBus<HealthEventMap>`.
## Implementation Notes
Timeout enforcement is delegated to the injected `TimersLike` rather than `setTimeout` so tests can drive timeouts deterministically without `vi.useFakeTimers()`. The cache is keyed by check name and stores a frozen `CheckResult` with an absolute `expiresAt` computed against `this.clock()` — clock skew between writes and reads cannot leak negative TTLs because we re-read the clock on every check. Cached reads tag results with `cached: true` so consumers can distinguish them. Failures inside `executeWithTimeout` are wrapped in `HealthCheckError(CHECK_EXECUTION_FAILED)` so the outer `runCheck` always sees a typed error; the `CHECK_TIMEOUT` branch is preserved separately so it can be surfaced in `details.timedOut`.

View File

@@ -0,0 +1,54 @@
# `http-check/http-check`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createHttpCheck` returns a `CheckFn` that probes an HTTP endpoint and reports `healthy` when the response status appears in `expectedStatusCodes`. It is the standard building block for "is upstream X reachable?" probes — used to check downstream APIs, gateways, internal dependency surfaces, and anything else with a `/health` or `/status` endpoint. The factory accepts an injectable `fetchImpl` so tests never touch real network sockets, and an injectable `Clock` so `responseTime` is deterministic.
## Features
- Method, headers, and expected-status-code overrides via the `HttpCheckConfig` argument.
- AbortController-driven per-request timeout that maps to `responseTime` + the human-readable `"HTTP check timed out after Xms"` message.
- Network errors are returned as `unhealthy` with `details.error` containing the raw message — not thrown.
- Zod boundary validation: empty `url`, empty `method`, non-positive `timeout` all throw `HealthCheckError(INVALID_OPTIONS)`.
- `fetchImpl` defaults to `globalThis.fetch` bound to `globalThis`; tests pass a stub.
## Usage: Basic
```ts
import { createHttpCheck } from "@suaveplan/health-check";
const check = createHttpCheck({ url: "https://upstream.example/health" });
const result = await check();
```
## Usage: Advanced
```ts
import { createHttpCheck } from "@suaveplan/health-check";
const check = createHttpCheck(
{
url: "https://internal.api/health",
method: "HEAD",
headers: { authorization: `Bearer ${token}` },
timeout: 1_500,
expectedStatusCodes: [200, 204],
fetchImpl: instrumentedFetch,
},
{ clock: () => performance.now() },
);
```
## API Reference
- `createHttpCheck(config, options?)` → `CheckFn`.
- `config: HttpCheckConfig` — `url` required; `method`, `headers`, `timeout`, `expectedStatusCodes`, `fetchImpl` optional.
- `options.clock?: Clock` — overrides `Date.now` for response-time measurement.
## Implementation Notes
The check never re-throws. Network errors, abort errors, and unexpected status codes are all funnelled into `CheckResult` objects so the `HealthChecker` can record them uniformly. Abort detection is case-insensitive on `"abort"` / `"timeout"` and also consults `controller.signal.aborted` so adapters that surface only a generic error message still classify correctly. The `responseTime` is measured between the clock readings around `fetchImpl` so it includes DNS, TCP, TLS, and the server's processing time — matching how operators read latency dashboards. The factory is pure: it validates options once and returns a closure that captures the normalised config, so the per-call cost is just the fetch + a small amount of bookkeeping.

View File

@@ -0,0 +1,64 @@
# `response-builder/response-builder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ResponseBuilder` adapts a `HealthChecker` into HTTP-shaped responses suitable for direct return from a Hono / Express / Fastify handler. It maps the abstract `CheckStatus` union to concrete HTTP status codes following the conventions baked into Kubernetes, ECS, and most load-balancer probes: `healthy` and `degraded` both produce `200` (so callers continue to receive traffic on degraded but still-serving services), `unhealthy` produces `503`, readiness produces `503` when any critical check is failing, and liveness always produces `200` so long as the process can run the handler.
## Features
- Three response factories — `health()`, `ready()`, `live()` — each returning `{ statusCode, body }`.
- Pure mapping: the builder never mutates the underlying checker.
- Status mapping is centralised in a single `statusToCode` switch so behaviour is auditable.
- Liveness is synchronous to keep `/live` cheap; readiness and health run the checks.
## Usage: Basic
```ts
import { HealthChecker, ResponseBuilder } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api" });
const builder = new ResponseBuilder(checker);
app.get("/health", async (req, res) => {
const r = await builder.health();
res.status(r.statusCode).json(r.body);
});
```
## Usage: Advanced
```ts
import { Hono } from "hono";
import { HealthChecker, ResponseBuilder } from "@suaveplan/health-check";
const checker = new HealthChecker({ name: "api" });
const builder = new ResponseBuilder(checker);
const app = new Hono()
.get("/health", async (c) => {
const r = await builder.health();
return c.json(r.body, r.statusCode);
})
.get("/ready", async (c) => {
const r = await builder.ready();
return c.json(r.body, r.statusCode);
})
.get("/live", (c) => {
const r = builder.live();
return c.json(r.body, r.statusCode);
});
```
## API Reference
- `new ResponseBuilder(checker)` — accepts an existing `HealthChecker`.
- `health()` → `Promise<HttpHealthResponse<HealthResponse>>`.
- `ready()` → `Promise<HttpHealthResponse<ReadinessResponse>>`.
- `live()` → `HttpHealthResponse<LivenessResponse>`.
## Implementation Notes
Returning `200` for `degraded` is deliberate: a degraded service is still serving traffic — operators want it to remain in the load-balancer pool while alerting fires on the underlying check. Flipping a degraded service into the unreachable pool would amplify outages because non-critical dependency hiccups would drop the entire fleet. `live()` deliberately performs no work so it is safe to wire to a tight liveness-probe interval without amplifying load on the dependencies. The builder is intentionally a thin adapter so a future plugin could wrap the same `HealthChecker` in a different protocol (gRPC, JSON-RPC, etc.) without re-implementing the orchestration.

View File

@@ -0,0 +1,53 @@
# `tcp-check/tcp-check`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createTcpCheck` returns a `CheckFn` that opens a TCP socket against a `host:port` pair and resolves to `healthy` once the `connect` event fires. It complements `createHttpCheck` for layer-4 probes — useful for databases, message brokers, and proxies that do not expose an HTTP `/health` route. The connector is fully injectable through `TcpConnectFn`, so tests use a deterministic stub and production code uses `node:net.connect`.
## Features
- Injectable `TcpConnectFn`: tests pass a fake socket with controlled `connect` / `error` emission; production uses `node:net`.
- Timeout enforcement via `setTimeout` that resolves to an `unhealthy` `CheckResult` with `details.timedOut === true`.
- Late events (a stray `error` after `connect`) are ignored once the promise settles.
- Zod boundary validation: missing `host`, out-of-range `port`, non-positive `timeout` all throw `HealthCheckError(INVALID_OPTIONS)`.
- Always destroys the socket — connect, error, and timeout paths each call `socket.destroy()`.
## Usage: Basic
```ts
import { createTcpCheck } from "@suaveplan/health-check";
const check = createTcpCheck({ host: "db.internal", port: 5432 });
const result = await check();
```
## Usage: Advanced
```ts
import { createTcpCheck } from "@suaveplan/health-check";
const check = createTcpCheck(
{
host: "db.internal",
port: 5432,
timeout: 500,
connectImpl: instrumentedConnect,
},
{ clock: () => performance.now() },
);
```
## API Reference
- `createTcpCheck(config, options?)` → `CheckFn`.
- `config: TcpCheckConfig` — `host` + `port` required; `timeout`, `connectImpl` optional.
- `options.clock?: Clock` — overrides `Date.now` for response-time measurement.
- `TcpSocketLike` — minimal structural socket the check drives (`on("connect" | "error", cb)` + `destroy()`).
## Implementation Notes
The check resolves rather than rejects on every failure path so the `HealthChecker` can treat result production uniformly. Late events (a stray `error` after `connect` resolved) are absorbed by `Promise.resolve`'s idempotency — only the first `resolve()` call wins, so the call site can stay lock-free. The default `connectImpl` performs a runtime cast from `node:net`'s `Socket` to `TcpSocketLike`; the cast is sound because `Socket` exposes the `on("connect" | "error", …)` and `destroy()` methods unchanged. The `connectImpl` injection point means consumers can also drive this check against an alternative transport (Unix-domain sockets, mock fixtures, etc.) without touching the public API.

View File

@@ -0,0 +1,47 @@
# `telemetry/telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`telemetry` registers the OpenTelemetry meter that the health-check package emits to on every public call. The instruments are bound lazily through `metrics.getMeter("@suaveplan/health-check")` so the package can be imported in production builds that have not opted into OTel — the API returns a no-op meter when no provider is registered. Tests install a real in-memory `MeterProvider` in `tests/setup/polyfills.ts` before any source module imports, so the same imports observed in production are exercised in unit tests.
## Features
- Counter `health_check_runs_total` — tagged with `name` and `status` for every executed dependency check.
- Counter `health_check_aggregate_status_total` — tagged with the resulting overall status for every call to `HealthChecker.health()`.
- Histogram `health_check_duration_ms` — recorded with the `name` tag for every executed dependency check.
- Helper `record*` functions used by `HealthChecker` so the call-sites stay legible.
## Usage: Basic
```ts
import { recordCheckRun } from "@suaveplan/health-check";
recordCheckRun("db", "healthy");
```
## Usage: Advanced
```ts
import {
aggregateStatusCounter,
checkDurationHistogram,
} from "@suaveplan/health-check";
aggregateStatusCounter.add(1, { status: "degraded", region: "eu-west-1" });
checkDurationHistogram.record(latencyMs, { name: "db", region: "eu-west-1" });
```
## API Reference
- `recordCheckRun(name, status)` — increment `health_check_runs_total`.
- `recordAggregateStatus(status)` — increment `health_check_aggregate_status_total`.
- `recordCheckDuration(name, durationMs)` — record on `health_check_duration_ms`.
- Raw `Counter` / `Histogram` instruments are exported for callers that need bespoke labels.
## Implementation Notes
The meter is constructed at module top-level so its caching matches OTel's per-process semantics. Switching the global `MeterProvider` after import will not re-bind the meter — this matches OTel best practice and lets tests inject a provider before the module evaluates. The package emits **no** labels containing user data; all tag values are static enum strings (`healthy` / `degraded` / `unhealthy`) or operator-supplied check names. This keeps cardinality bounded and prevents accidental leakage.

View File

@@ -0,0 +1,186 @@
# @suaveplan/logger - Foundation logging package — structured JSON output, level filtering, redaction, sampling, OTel correlation, and pluggable sinks.
## Summary
Enterprise-grade structured logger for Suaveplan. Provides a full pipeline: level filtering → OTel trace enrichment → sampling → redaction → sinks. Supports multiple output strategies (console, JSON/NDJSON, event bus, storage) with buffering and backpressure.
<!-- begin:auto-docs -->
## Documentation tree
- [abstract](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-logger](./src/abstract/abstract-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [console](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [console-logger](./src/console/console-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-bridge](./src/error-bridge/error-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [logger-error](./src/errors/logger-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [event](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [event-logger](./src/event/event-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ - [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel-context](./src/otel/otel-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-record](./src/record/log-record.md) — <!-- begin:auto-docs --> _(no submodules)_- [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [redaction](./src/redaction/redaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sampling](./src/sampling/sampling.md) — <!-- begin:auto-docs --> _(no submodules)_- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_ - [buffered-sink](./src/sink/buffered-sink.md) — `BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log recor…
- [console-sink](./src/sink/console-sink.md) — `ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a si…
- [json-sink](./src/sink/json-sink.md) — `JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a…
- [sink](./src/sink/sink.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [storage-logger](./src/storage/storage-logger.md) — <!-- begin:auto-docs --> _(no submodules)_
## Features
- **Structured logging** — `LogRecord` with level, message, ISO timestamp, context bag, trace IDs
- **Level filtering** — `debug | info | warn | error` with hierarchical cutoff
- **OTel enrichment** — auto-injects `trace_id` / `span_id` from the active OpenTelemetry span
- **Sampling** — `HeadSampler` (deterministic FNV1a), `TailSampler` (buffered), `RuleSampler`, `SampleAll/None`
- **Redaction** — path-based and regex-pattern rules; respects `@suaveplan/error` `isSensitive()` markers
- **Pluggable sinks** — `ConsoleSink`, `JsonSink` (NDJSON), `BufferedSink` (async queue, backpressure), `MultiSink`
- **Concrete loggers** — `ConsoleLogger`, `EventLogger` (event-bus integration), `StorageLogger` (KV persistence)
- **Error bridge** — installs a global `SuaveplanError` interceptor so unhandled errors are logged automatically
- **Factory API** — `createLogger(config)` for declarative setup; `initLogger(config)` for one-call bootstrap
## Installation
```bash
bun add @suaveplan/logger
```
## Quick Start
```ts
import { initLogger } from "@suaveplan/logger/init/init";
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
// Option 1 — factory bootstrap
const logger = initLogger({ type: "console", level: "info" });
logger.info("server started", { port: 3000 });
// Option 2 — direct construction
const log = new ConsoleLogger({ level: "debug", colors: true, timestamp: true });
log.debug("connecting", { host: "db.local" });
log.warn("slow query", { ms: 412 });
```
## Modules
| Module | Description |
|--------|-------------|
| [`src/abstract/abstract-logger`](src/abstract/abstract-logger.md) | Base pipeline class — all concrete loggers extend this |
| [`src/record/log-record`](src/record/log-record.md) | `LogRecord` type and Zod validation schemas |
| [`src/errors/logger-error`](src/errors/logger-error.md) | `LoggerError` + `LOGGER_CODES` error registry |
| [`src/otel/otel-context`](src/otel/otel-context.md) | OTel trace/span context extractor |
| [`src/sampling/sampling`](src/sampling/sampling.md) | All sampler implementations and `createSampler` factory |
| [`src/redaction/redaction`](src/redaction/redaction.md) | `RedactionEngine` and `applyRedaction` utility |
| [`src/sink/sink`](src/sink/sink.md) | Sink interface, `MultiSink`, `flushSink`, `closeSink` |
| [`src/sink/console-sink`](src/sink/console-sink.md) | ANSI-colored human-readable console output |
| [`src/sink/json-sink`](src/sink/json-sink.md) | NDJSON structured output to any writer |
| [`src/sink/buffered-sink`](src/sink/buffered-sink.md) | Async queue with configurable overflow policies |
| [`src/console/console-logger`](src/console/console-logger.md) | Ready-to-use console logger |
| [`src/event/event-logger`](src/event/event-logger.md) | Event-bus logger with per-level subscriptions |
| [`src/storage/storage-logger`](src/storage/storage-logger.md) | KV-backed persistent logger with batch flush |
| [`src/error-bridge/error-bridge`](src/error-bridge/error-bridge.md) | Global `SuaveplanError` interceptor |
| [`src/factory/factory`](src/factory/factory.md) | Declarative `createLogger` factory |
| [`src/init/init`](src/init/init.md) | One-call `initLogger` bootstrap with global accessor |
## Usage: Redaction
```ts
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
const log = new ConsoleLogger({
level: "info",
redaction: {
rules: [
{ kind: "path", path: "user.password" },
{ kind: "pattern", pattern: /token/i },
],
},
});
log.info("login", { user: { password: "s3cr3t", email: "a@b.com" }, token: "xyz" });
// → password and token fields are replaced with "[REDACTED]"
```
## Usage: Sampling
```ts
import { createLogger } from "@suaveplan/logger/factory/factory";
const logger = createLogger({
type: "console",
level: "debug",
sampling: { kind: "head", options: { rate: 0.1 } }, // keep 10%
});
```
## Usage: Buffered JSON sink
```ts
import { BufferedSink } from "@suaveplan/logger/sink/buffered-sink";
import { JsonSink } from "@suaveplan/logger/sink/json-sink";
const inner = new JsonSink({ writer: (line) => fs.appendFileSync("app.log", line + "\n") });
const sink = new BufferedSink({ sink: inner, maxQueueSize: 1000, overflowPolicy: "drop" });
// ... on shutdown:
await sink.flush();
await sink.close();
```
## Usage: StorageLogger
```ts
import { StorageLogger } from "@suaveplan/logger/storage/storage-logger";
const logger = new StorageLogger(kvStorage, {
level: "info",
autoFlush: true,
flushInterval: 10_000,
maxBatchSize: 100,
});
logger.error("payment failed", { orderId: "ord_123" });
const recent = await logger.getLogs({ limit: 50 });
await logger.clearLogs(new Date(Date.now() - 7 * 86400_000)); // purge >7 days old
```
## Usage: Error bridge
```ts
import { installSuaveplanErrorInterceptor } from "@suaveplan/logger/error-bridge/error-bridge";
import { ConsoleLogger } from "@suaveplan/logger/console/console-logger";
const log = new ConsoleLogger({ level: "warn" });
installSuaveplanErrorInterceptor(log); // all future SuaveplanErrors are logged automatically
```
## API Reference
See each module's co-located `.md` file (linked in the table above) for full parameter and return-type documentation.
## License
Private — Suaveplan internal use only.

View File

@@ -0,0 +1,109 @@
# abstract-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AbstractLogger` is the backbone of `@suaveplan/logger`. It owns the log pipeline: level filtering → metadata merging → redaction → OTel context attachment → structured record construction → sampling decision → sink dispatch → backward-compatible `writeLog()` hook. Every concrete logger (Console, Event, Storage) extends it and only overrides `writeLog()`. By centralising the pipeline here, every logger automatically inherits enterprise features (redaction, sampling, OTel correlation, metrics, clock injection) without duplicating logic.
The `Logger<TMetadata>` interface provides the stable consumer API. Consumers that only need to accept a logger in function signatures should type against `Logger<TMetadata>` rather than `AbstractLogger<TMetadata>`.
## Features
- Level filtering with runtime `setLevel()` / `getLevel()`
- `enable()` / `disable()` master switch
- Pluggable `sinks: Sink[]` — each `LogRecord` is dispatched to all sinks after pipeline processing
- Declarative redaction via `RedactionEngine` — applied to merged context before record construction
- Pluggable sampling via `Sampler` instance or `SamplingPolicy` discriminated union
- OTel trace/span ID auto-attachment from `@opentelemetry/api` active span (configurable, default on)
- Injectable `clock: Clock` from `@suaveplan/types` for deterministic timestamps in tests
- OTel metrics: `logger.records_total` counter, `logger.record_size_bytes` histogram, `logger.dropped_total` counter — all no-op when no OTel exporter is registered
- `getMetrics()` returns a snapshot including `droppedBySampling`, `droppedByBackpressure`, and `droppedBySinkError` counts. The three counters are mutually exclusive: a sink throw or rejection increments `droppedBySinkError` (and is reported to a fallback sink as `LoggerError(LOGGER.SINK)`), NOT `droppedByBackpressure`. Tests asserting on dropped-record counts must use the counter that matches the failure mode.
- Per-sink failure isolation: a thrown sink does NOT halt dispatch to remaining sinks. Failures are emitted to a configurable `fallbackSink` (default: bare `console.error`). This is what makes multi-sink logging safe to compose — one bad sink cannot take the others down.
## Usage: Basic Example
```ts
import { AbstractLogger } from "@suaveplan/logger/abstract";
import type { LogEntry } from "@suaveplan/logger/record";
class MyLogger extends AbstractLogger {
protected writeLog(entry: LogEntry): void {
// custom output logic
process.stdout.write(`[${entry.level}] ${entry.message}\n`);
}
}
const logger = new MyLogger({ level: "debug" });
logger.info("App started");
```
## Usage: Advanced Example
```ts
import { AbstractLogger } from "@suaveplan/logger/abstract";
import { JsonSink } from "@suaveplan/logger/sink";
import { HeadSampler } from "@suaveplan/logger/sampling";
class ProductionLogger extends AbstractLogger {
protected writeLog(_entry: LogEntry): void {
// no-op: all output goes through sinks
}
}
const logger = new ProductionLogger({
level: "info",
sinks: [new JsonSink()],
redaction: { rules: [{ kind: "path", path: "user.token" }] },
sampling: new HeadSampler({ rate: 0.1 }),
otel: true,
});
logger.info("Payment processed", { orderId: "o-1", user: { token: "abc" } });
// Sink sees: { ..., ctx: { orderId: "o-1", user: { token: "[REDACTED]" } } }
```
## API Reference
### `interface Logger<TMetadata>`
```ts
interface Logger<TMetadata> {
debug(message, metadata?, tags?): void;
info(message, metadata?, tags?): void;
warn(message, metadata?, tags?): void;
error(message, metadata?, tags?): void;
setLevel(level): void;
getLevel(): LogLevel;
enable(): void;
disable(): void;
isEnabled(): boolean;
getMetrics(): LoggerMetrics;
resetMetrics(): void;
}
```
### `abstract class AbstractLogger<TMetadata> implements Logger<TMetadata>`
**Constructor**: `new ConcreteLogger(config?: AbstractLoggerConfig<TMetadata>)`
| Config field | Type | Default | Description |
|-------------|------|---------|-------------|
| `level` | `LogLevel` | `"info"` | Minimum emit level |
| `enabled` | `boolean` | `true` | Master on/off |
| `metadata` | `TMetadata` | `{}` | Default context merged into every record |
| `tags` | `string[]` | `[]` | Global tags merged into every record |
| `sinks` | `Sink[]` | `[]` | Pluggable output targets |
| `redaction` | `RedactionOptions` | `{}` | Field-path and pattern redaction rules |
| `sampling` | `SamplingPolicy \| Sampler` | keep-all | Sampling policy |
| `clock` | `Clock` | `Date` | Injectable clock for timestamps |
| `otel` | `boolean` | `true` | Auto-attach OTel trace context |
#### `protected abstract writeLog(entry: LogEntry<TMetadata>): void`
Called after sink dispatch for backward-compatible concrete logger implementations.
## Implementation Notes
Tags are stored in the log record under `ctx.__tags` (a `string[]`) rather than as a top-level field because the `LogRecord` schema has only `ctx` for context data. Sinks can extract `__tags` for display; the `ConsoleSink` does this. The `writeLog()` hook receives a `LogEntry` (the legacy shape) so that subclasses written for the old API still function without changes.

View File

@@ -0,0 +1,70 @@
# console-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ConsoleLogger` extends `AbstractLogger` to provide human-readable, ANSI-coloured terminal output — the default logger for development and local debugging. It wires a `ConsoleSink` as the first element of its sink chain, so the full enterprise pipeline (level filtering, redaction, sampling, OTel correlation, metrics) runs before any output reaches the terminal. Configuration mirrors the existing v1 API exactly, preserving backward compatibility.
## Features
- ANSI colour per severity level (configurable at construction and runtime)
- ISO timestamp prefix (configurable)
- Optional tag rendering as `#tag` tokens
- Optional metadata rendering as compact JSON suffix
- All `AbstractLogger` features: redaction, sampling, OTel, clock injection, `getMetrics()`
## Usage: Basic Example
```ts
import { ConsoleLogger } from "@suaveplan/logger/console";
const log = new ConsoleLogger({ level: "info" });
log.info("Server started", { port: 3000 });
```
## Usage: Advanced Example
```ts
import { ConsoleLogger } from "@suaveplan/logger/console";
const log = new ConsoleLogger({
level: "debug",
colors: true,
timestamp: true,
includeMetadata: true,
tags: ["api"],
redaction: { rules: [{ kind: "pattern", pattern: /token|secret/i }] },
});
log.info("User authenticated", { userId: "u-1", token: "abc" }, ["auth"]);
// [2024-…] [INFO] [#api #auth] User authenticated
// { "userId": "u-1", "token": "[REDACTED]" }
```
## API Reference
### `class ConsoleLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new ConsoleLogger(config?: ConsoleLoggerConfig<TMetadata>)`
Extends `AbstractLoggerConfig` with:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `colors` | `boolean` | `true` | ANSI colour codes |
| `timestamp` | `boolean` | `true` | ISO timestamp prefix |
| `includeMetadata` | `boolean` | `false` | Compact JSON metadata suffix |
| `includeTags` | `boolean` | `true` | `#tag` tokens inline |
#### Runtime mutation methods
- `setColors(enabled: boolean)`
- `setTimestamp(enabled: boolean)`
- `setIncludeMetadata(enabled: boolean)`
- `setIncludeTags(enabled: boolean)`
## Implementation Notes
The `writeLog()` hook is a no-op — all output is delegated to `ConsoleSink` registered in the `sinks` array. This ensures records pass through redaction and sampling (handled by `AbstractLogger`) before any console call. Runtime mutation methods write directly to the `ConsoleSink`'s protected fields.

View File

@@ -0,0 +1,72 @@
# error-bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-bridge` connects `@suaveplan/error`'s `SuaveplanError` to the logger, providing a single function (`logSuaveplanError`) that routes a structured error to whichever logger was installed via `installSuaveplanErrorInterceptor`. The install function is idempotent — calling it multiple times is safe and returns the same uninstall handle, preventing double-registration in environments where multiple modules bootstrap logging.
The implementation differs from the main-branch version: rather than hooking into a global interceptor registry (which does not yet exist in `@suaveplan/error`), error routing is explicit — callers invoke `logSuaveplanError(err)` in their catch blocks. `installSuaveplanErrorInterceptor` still establishes the logger reference and provides the idempotency/cleanup contract.
## Features
- `installSuaveplanErrorInterceptor(logger?)` — stores a logger; defaults to a `ConsoleLogger`; idempotent
- `logSuaveplanError(err)` — routes any `SuaveplanError` to the active logger at `"error"` level with code, context, stack, and serialised JSON payload
- `SEVERITY_TO_LEVEL` — mapping from `SuaveplanError` severity strings to `LogLevel`
- `__resetDefaultLoggerForTests()` — test-only helper to clear all state between test cases
## Usage: Basic Example
```ts
import { installSuaveplanErrorInterceptor, logSuaveplanError } from "@suaveplan/logger/error-bridge";
import { ConsoleLogger } from "@suaveplan/logger/console";
const logger = new ConsoleLogger({ level: "info" });
const uninstall = installSuaveplanErrorInterceptor(logger);
try {
await doRiskyThing();
} catch (err) {
if (err instanceof SuaveplanError) logSuaveplanError(err);
}
```
## Usage: Advanced Example
```ts
import { installSuaveplanErrorInterceptor, __resetDefaultLoggerForTests } from "@suaveplan/logger/error-bridge";
// In bootstrap:
const uninstall = installSuaveplanErrorInterceptor(); // uses default ConsoleLogger
// In tests (afterEach):
afterEach(() => __resetDefaultLoggerForTests());
// Multiple modules can safely call install — only the first call registers:
installSuaveplanErrorInterceptor(); // returns same uninstall as above
```
## API Reference
### `installSuaveplanErrorInterceptor(logger?: Logger): UninstallErrorInterceptor`
Stores `logger` (or a default `ConsoleLogger`) as the active error sink. Returns the uninstall function. Re-calling returns the existing uninstall without side effects.
### `logSuaveplanError(err: SuaveplanError): void`
Routes `err` to the active logger at `"error"` level. Context includes `code`, `context`, `stack`, and the JSON-serialised wire payload from `toJSON(err)`.
### `SEVERITY_TO_LEVEL: SeverityLevelMap`
```ts
{ fatal: "error", error: "error", warning: "warn", info: "info" }
```
### `__resetDefaultLoggerForTests(): void`
Clears all module-level state. Call in `afterEach` when testing code that uses the error bridge.
## Implementation Notes
The active logger is stored in module-level state (a singleton pattern). This is intentional — bootstrap code typically runs once, and the idempotency guard prevents multiple callers from interfering. `__resetDefaultLoggerForTests` exists as an escape hatch for test environments where module state must be reset between cases.

View File

@@ -0,0 +1,81 @@
# logger-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LoggerError` is the structured error class used by every failure path in `@suaveplan/logger`. It extends `SuaveplanError` from `@suaveplan/error`, ensuring all logger errors carry a stable, machine-readable code, an optional cause chain, and a typed context payload. Consumer code can discriminate on `err.code` without string-matching the message.
`LOGGER_CODES` is a frozen constant record produced by `defineCodes` that maps each failure reason to its fully-qualified `"LOGGER.<REASON>"` string. Using the constant instead of raw strings ensures a typo is caught at compile time.
## Features
- Extends `SuaveplanError<LoggerErrorCode>` — inherits `code`, `cause`, `context`, `toJSON()`, `toSpanEvent()`, and severity fields
- Four stable codes: `LOGGER.SINK`, `LOGGER.SERIALIZE`, `LOGGER.CONFIG`, `LOGGER.FLUSH`
- Frozen `LOGGER_CODES` constant for type-safe code references
## Usage: Basic Example
```ts
import { LOGGER_CODES, LoggerError } from "@suaveplan/logger/errors";
throw new LoggerError(LOGGER_CODES.SINK, "Sink write failed", {
cause: originalError,
});
```
## Usage: Advanced Example
```ts
import { LOGGER_CODES, LoggerError } from "@suaveplan/logger/errors";
try {
await sink.write(record);
} catch (err) {
throw new LoggerError(LOGGER_CODES.SINK, "sink.write threw", {
cause: err instanceof Error ? err : undefined,
context: { sinkType: "JsonSink", recordLevel: record.level },
});
}
// Consumer discrimination:
if (caughtErr instanceof LoggerError && caughtErr.code === LOGGER_CODES.FLUSH) {
// handle flush-aborted case specifically
}
```
## API Reference
### `LOGGER_CODES`
```ts
const LOGGER_CODES: {
readonly SINK: "LOGGER.SINK";
readonly SERIALIZE: "LOGGER.SERIALIZE";
readonly CONFIG: "LOGGER.CONFIG";
readonly FLUSH: "LOGGER.FLUSH";
}
```
Frozen record of all stable error codes for `@suaveplan/logger`.
### `class LoggerError extends SuaveplanError<LoggerErrorCode>`
**Constructor**: `new LoggerError(code, messageKey, options?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `LoggerErrorCode` | Stable machine-readable code |
| `messageKey` | `string` | Human-readable message or i18n key |
| `options` | `SuaveplanErrorOptions?` | Optional `{ cause, context, severity }` |
Inherits all `SuaveplanError` methods: `toJSON()`, `toSpanEvent()`, `walkCauses()`.
### `type LoggerErrorCode`
Union of all four stable code strings: `"LOGGER.SINK" | "LOGGER.SERIALIZE" | "LOGGER.CONFIG" | "LOGGER.FLUSH"`.
## Implementation Notes
`LoggerError` delegates everything to `SuaveplanError` — the constructor body is intentionally minimal. The four codes cover the complete surface: sink I/O failures (`SINK`), JSON serialisation failures (`SERIALIZE`), invalid configuration detected at construction time (`CONFIG`), and flush cancellation via `AbortSignal` (`FLUSH`). Adding new codes requires updating the `LOGGER_CODES` call and the `LoggerErrorCode` union — both are in this file to keep changes atomic.

View File

@@ -0,0 +1,89 @@
# event-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EventLogger` routes each log entry through an `EventBusLike<LoggerEvents>` emitter after the standard `AbstractLogger` pipeline (level filtering, redaction, sampling, OTel). It exists for environments where other parts of an application need to react to log events — for example, a UI component that surfaces recent errors, a test harness that asserts on emitted entries, or an aggregator that combines logs from multiple services. The emitter is injected so any conformant `EventBusLike` works (including `@suaveplan/events`' `EventBus`).
## Features
- Emits `"log"` for every accepted record
- Emits `"log:debug"`, `"log:info"`, `"log:warn"`, `"log:error"` per level
- Emits `"metrics:updated"` after every log with the current `LoggerMetrics` snapshot
- `on*/off*` helper methods for typed subscription/unsubscription
- Proper unsubscription via the `() => void` handle returned by `EventBusLike.on`
- Compatible with any `EventBusLike<LoggerEvents<TMetadata>>` — no hard dep on `@suaveplan/events`
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { EventLogger } from "@suaveplan/logger/event";
const bus = new EventBus();
const logger = new EventLogger(bus, { level: "info" });
logger.onError((entry) => {
alerting.report(entry.message, entry.metadata);
});
logger.error("Payment gateway unreachable");
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { EventLogger } from "@suaveplan/logger/event";
const bus = new EventBus();
const logger = new EventLogger(bus, { level: "debug" });
// Subscribe
const handleInfo = (entry) => console.log("[INFO]", entry.message);
logger.onInfo(handleInfo);
// Log
logger.info("Started");
// Unsubscribe
logger.offInfo(handleInfo);
```
## API Reference
### `class EventLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new EventLogger(emitter, config?)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `emitter` | `LoggerEventBus<TMetadata>` | Any `EventBusLike` conformant bus |
| `config` | `EventLoggerConfig<TMetadata>?` | Inherits all `AbstractLoggerConfig` options |
#### Subscription helpers (all return `this`)
`onLog` / `offLog`, `onDebug` / `offDebug`, `onInfo` / `offInfo`, `onWarn` / `offWarn`, `onError` / `offError`, `onMetricsUpdate` / `offMetricsUpdate`
#### `getEmitter(): LoggerEventBus<TMetadata>`
Returns the injected bus.
### `interface LoggerEvents<TMetadata>`
```ts
{
"log": LogEntry<TMetadata>;
"log:debug": LogEntry<TMetadata>;
"log:info": LogEntry<TMetadata>;
"log:warn": LogEntry<TMetadata>;
"log:error": LogEntry<TMetadata>;
"metrics:updated": LoggerMetrics;
}
```
## Implementation Notes
Unsubscription is implemented by storing the `() => void` cleanup function returned by `emitter.on()` in a `WeakMap<listener, cleanup>`. Calling `off*` looks up and invokes the cleanup. The `WeakMap` avoids memory leaks when the listener function is garbage collected without explicit unsubscription.

View File

@@ -0,0 +1,56 @@
# factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createLogger` is an overloaded factory that returns the concrete logger class corresponding to the `type` discriminant in the configuration object. It eliminates the need for consumers to import each concrete class separately and provides a type-narrowing API — the return type is `ConsoleLogger`, `StorageLogger`, or `EventLogger` depending on which overload matches, so consumers get full intellisense on type-specific methods.
## Features
- Overloaded TypeScript signatures for `"console"`, `"storage"`, and `"event"` types
- Returns the precise concrete class (`ConsoleLogger | StorageLogger | EventLogger`)
- Exhaustive switch with TypeScript `never` guard — compile-time protection against unhandled types
- Forwards all config options to the underlying constructor unchanged
## Usage: Basic Example
```ts
import { createLogger } from "@suaveplan/logger/factory";
const log = createLogger({ type: "console", level: "info", colors: false });
log.info("Server ready");
```
## Usage: Advanced Example
```ts
import { createLogger } from "@suaveplan/logger/factory";
import type { LoggerFactoryConfig } from "@suaveplan/logger/factory";
// Dynamic selection at runtime
const config: LoggerFactoryConfig = process.env.LOG_TARGET === "json"
? { type: "console", colors: false }
: { type: "console", colors: true };
const log = createLogger(config);
```
## API Reference
### `createLogger(config): ConsoleLogger | StorageLogger | EventLogger`
Three overloads — see `factory.types.ts` for `ConsoleLoggerFactoryConfig`, `StorageLoggerFactoryConfig`, and `EventLoggerFactoryConfig`.
### `type LoggerFactoryConfig<TMetadata>`
Discriminated union with `type: "console" | "storage" | "event"`.
## Implementation Notes
The `_exhaustive: never` pattern on the default branch ensures TypeScript catches missing cases at compile time without requiring runtime `throw new Error("unreachable")` to satisfy coverage — though the throw is retained as a safety net for JavaScript consumers.
### Factory Shape
`createLogger` is the declarative entry-point for assembling a concrete logger from a `LoggerConfig` bundle: level, sinks, sampler, redactor, OTel trace enrichment, and global-error bridge. Each option has a sensible default so the zero-config call `createLogger({ name: "app" })` yields a console-sink logger at `info` level. Returned loggers satisfy the `LoggerLike` contract from `@suaveplan/types` for safe downstream injection.

View File

@@ -0,0 +1,65 @@
# init
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`initLogger` is the single bootstrap entry point for `@suaveplan/logger`. A caller invokes it once at application startup to get a fully configured logger with the `@suaveplan/error` interceptor bridge already wired. The function is idempotent-safe — the underlying `installSuaveplanErrorInterceptor` guards against double-registration — so calling `initLogger` from multiple modules during startup is safe.
## Features
- Accepts an optional pre-built `logger` to wire, otherwise creates a `ConsoleLogger`
- Accepts `consoleConfig` to configure the default `ConsoleLogger` without constructing it manually
- Wires `installSuaveplanErrorInterceptor` and returns the `uninstall` handle alongside the logger
- Returns a typed `InitLoggerResult` so callers can cleanly tear down on shutdown
## Usage: Basic Example
```ts
import { initLogger } from "@suaveplan/logger/init";
const { logger, uninstall } = initLogger();
logger.info("Application started");
// On shutdown:
process.on("SIGTERM", () => uninstall());
```
## Usage: Advanced Example
```ts
import { initLogger } from "@suaveplan/logger/init";
import { JsonSink } from "@suaveplan/logger/sink";
const jsonSink = new JsonSink({ writer: writeToLogAggregator });
const { logger, uninstall } = initLogger({
consoleConfig: {
level: "info",
sinks: [jsonSink],
redaction: { rules: [{ kind: "pattern", pattern: /token|secret/i }] },
},
});
logger.info("Server ready", { port: 8080 });
```
## API Reference
### `initLogger<TMetadata>(options?): InitLoggerResult`
| Option | Type | Description |
|--------|------|-------------|
| `logger` | `Logger?` | Pre-built logger to wire into the error bridge |
| `consoleConfig` | `ConsoleLoggerConfig?` | Config for the default `ConsoleLogger` when `logger` is absent |
Returns `{ logger: Logger, uninstall: () => void }`.
## Implementation Notes
`initLogger` is intentionally thin — it delegates to `ConsoleLogger` and `installSuaveplanErrorInterceptor`. Keeping it as a thin coordinator makes it testable: callers that need a custom logger or specific sink configuration pass them in, rather than configuring a global singleton.
### Startup Hook
`initLogger` is the process-startup counterpart to `createLogger` — it installs a global error bridge that catches uncaught exceptions, unhandled promise rejections, and `SIGTERM` / `SIGINT` signals, then flushes any buffered sinks before exiting. Calling `initLogger` more than once is a no-op: the module tracks its own install state on a `Symbol.for` marker.

View File

@@ -0,0 +1,62 @@
# otel-context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel-context` extracts the active OpenTelemetry trace context (`trace_id` and `span_id`) from the current OTel context and returns them as lowercase hex strings. This allows `AbstractLogger` to automatically annotate every log record with the correlation IDs of the span in which the log call was made — without requiring callers to pass trace context explicitly. When no span is active or the OTel API is configured with a no-op provider, the function returns an empty object without throwing.
## Features
- Reads `trace_id` and `span_id` from the active span via `@opentelemetry/api`
- Returns `{}` when no span is active, the span context has all-zero IDs (no-op provider), or the OTel API throws unexpectedly
- Never throws — defensive `try/catch` wraps the entire extraction
- Zero side effects: pure function, no caching, safe to call on every log line
## Usage: Basic Example
```ts
import { getOtelTraceContext } from "@suaveplan/logger/otel";
const traceCtx = getOtelTraceContext();
// When inside an active span: { trace_id: "abc123...", span_id: "def456..." }
// When no span is active: {}
```
## Usage: Advanced Example
```ts
import { buildRecord } from "@suaveplan/logger/record";
import { getOtelTraceContext } from "@suaveplan/logger/otel";
// Used internally by AbstractLogger on every log call:
const traceCtx = getOtelTraceContext();
const record = buildRecord(level, msg, ctx, ts, traceCtx, errorPayload);
// record.trace_id and record.span_id are populated when a span is active
```
## API Reference
### `getOtelTraceContext(): OtelTraceContext`
Returns an object with optional `trace_id` and `span_id` string fields.
Returns `{}` when:
- No span is active in the current context
- The active span has an all-zero trace ID (indicates a no-op OTel SDK)
- `@opentelemetry/api` throws for any reason
### `interface OtelTraceContext`
```ts
interface OtelTraceContext {
trace_id?: string;
span_id?: string;
}
```
## Implementation Notes
The zero-ID guard (`00000000000000000000000000000000`) exists because the OTel no-op provider returns a valid-looking `SpanContext` with all-zero IDs rather than returning `undefined`. Emitting those zero IDs would mislead log consumers into thinking a real trace exists. The `try/catch` guard exists because OTel API implementations can throw in unusual environments (e.g., incompatible versions, patched globals in test environments).

View File

@@ -0,0 +1,88 @@
# log-record
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`log-record` defines the canonical structured format (`LogRecord`) that every log emission produces before being handed to sinks. By standardising on this shape — `ts`, `level`, `msg`, `ctx`, `trace_id`, `span_id`, `error` — every downstream component (redaction, sampling, sinks, OTel) operates on a single well-typed contract rather than ad-hoc strings. The Zod schema in `log-record.schemas.ts` provides runtime validation and serves as the serialisation contract.
## Features
- `LogRecord` structured type with ISO timestamp, level, message, merged context, optional OTel IDs, optional error payload
- `LOG_LEVELS` numeric ordering map for level comparison
- `isLogLevel(value)` type guard
- `shouldLog(current, candidate)` — returns `true` when `candidate >= current`
- `buildRecord(...)` — constructs a `LogRecord`, omitting undefined optional fields
- Zod schema `LogRecordSchema` with `z.datetime()` validation for `ts`
## Usage: Basic Example
```ts
import { buildRecord } from "@suaveplan/logger/record";
const record = buildRecord(
"info",
"User signed in",
{ userId: "u-123" },
new Date().toISOString(),
);
// { ts: "...", level: "info", msg: "User signed in", ctx: { userId: "u-123" } }
```
## Usage: Advanced Example
```ts
import { buildRecord, shouldLog } from "@suaveplan/logger/record";
import { LogRecordSchema } from "@suaveplan/logger/record.schemas";
// Only build the record if the level passes the filter
if (shouldLog("warn", "error")) {
const record = buildRecord(
"error",
"Payment failed",
{ orderId: "o-42" },
clock.now().toISOString(),
{ trace_id: "abc123ef", span_id: "def456ab" },
error.toJSON(),
);
// Validate before sink dispatch (defensive — normally guaranteed by pipeline)
const parsed = LogRecordSchema.safeParse(record);
if (!parsed.success) throw new Error("Invariant: invalid LogRecord");
}
```
## API Reference
### `LOG_LEVELS: Record<LogLevel, number>`
Numeric weights: `{ debug: 0, info: 1, warn: 2, error: 3 }`.
### `isLogLevel(value): value is LogLevel`
Type guard returning `true` for `"debug" | "info" | "warn" | "error"`.
### `shouldLog(current, candidate): boolean`
Returns `true` when `LOG_LEVELS[candidate] >= LOG_LEVELS[current]`.
### `buildRecord(level, msg, ctx, ts, traceCtx?, errorPayload?): LogRecord`
| Parameter | Type | Description |
|-----------|------|-------------|
| `level` | `LogLevel` | Severity |
| `msg` | `string` | Human-readable message |
| `ctx` | `Record<string, unknown>` | Merged metadata/tags |
| `ts` | `string` | ISO-8601 timestamp |
| `traceCtx` | `{ trace_id?, span_id? }?` | OTel context |
| `errorPayload` | `unknown?` | Serialised error payload |
Omits `trace_id`, `span_id`, and `error` keys entirely when their values are `undefined`.
### `LogRecordSchema` (from `log-record.schemas.ts`)
Zod schema validating `LogRecord`. Fields: `ts` (`z.string().datetime()`), `level` (`LogLevelSchema`), `msg` (`z.string()`), `ctx` (`z.record(z.unknown())`), optional `trace_id`, `span_id`, `error`.
## Implementation Notes
`buildRecord` deliberately omits keys with `undefined` values rather than setting them to `undefined` — this keeps NDJSON payloads lean and avoids confusing consumers that test `"key" in record`. The Zod schema defaults `ctx` to `{}` so consumers can omit it when there is no additional context.

View File

@@ -0,0 +1,91 @@
# redaction
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The redaction module provides declarative, irreversible sanitisation of log context objects before they reach any sink. It supports two rule kinds: field-path rules (dot-notation with `*` wildcard) and pattern rules (regex applied to leaf key names). It also automatically redacts any value wrapped in `Sensitive<T>` from `@suaveplan/error`. Redaction always operates on a deep clone — the original context is never mutated.
The invariant is strict: once a field matches a rule or is `Sensitive`, its value becomes the label `"[REDACTED]"` and can never be recovered from the log record. This ensures sensitive data (passwords, tokens, PII) never reaches log storage or downstream consumers.
## Features
- `PathRedactionRule` — matches exact dot-notation paths; supports `*` wildcard at any segment
- `PatternRedactionRule` — applies a `RegExp` to every leaf key name
- Automatic detection of `Sensitive<T>` wrappers via `isSensitive()` from `@suaveplan/error`
- Deep cloning before redaction — original object untouched
- Configurable replacement label (default `"[REDACTED]"`) and max traversal depth (default `10`)
- `RedactionEngine` class for reuse across many records; `applyRedaction()` functional one-shot helper
- Property-based tests (fast-check) verify the redaction invariant across arbitrary inputs
## Usage: Basic Example
```ts
import { applyRedaction } from "@suaveplan/logger/redaction";
const clean = applyRedaction(
{ userId: "u-1", password: "hunter2", metadata: { token: "abc" } },
{
rules: [
{ kind: "path", path: "password" },
{ kind: "path", path: "metadata.token" },
],
},
);
// { userId: "u-1", password: "[REDACTED]", metadata: { token: "[REDACTED]" } }
```
## Usage: Advanced Example
```ts
import { RedactionEngine } from "@suaveplan/logger/redaction";
import { markSensitive } from "@suaveplan/error/redact";
const engine = new RedactionEngine({
rules: [
{ kind: "pattern", pattern: /password|secret|token/i },
{ kind: "path", path: "*.credentials" },
],
redactedLabel: "***",
maxDepth: 5,
});
// Sensitive<T> is auto-detected without a rule:
const ctx = { ssn: markSensitive("123-45-6789"), name: "Alice" };
const clean = engine.redact(ctx);
// { ssn: "***", name: "Alice" }
```
## API Reference
### `class RedactionEngine`
**Constructor**: `new RedactionEngine(options?: RedactionOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rules` | `RedactionRule[]` | `[]` | Path and pattern rules |
| `redactedLabel` | `string` | `"[REDACTED]"` | Replacement value |
| `maxDepth` | `number` | `10` | Maximum recursion depth |
#### `.redact(ctx): Record<string, unknown>`
Returns a deep-cloned copy of `ctx` with all matching fields replaced by the label.
### `applyRedaction(ctx, options?): Record<string, unknown>`
Functional one-shot wrapper — creates a `RedactionEngine` and calls `.redact()`.
### `type RedactionRule`
```ts
type RedactionRule = PathRedactionRule | PatternRedactionRule;
interface PathRedactionRule { kind: "path"; path: string; }
interface PatternRedactionRule { kind: "pattern"; pattern: RegExp; }
```
## Implementation Notes
Path matching splits both the rule path and the candidate key path on `.`, then compares segments one-by-one, treating `*` as a wildcard. Paths of different lengths never match. The wildcard only covers a single segment — `a.*.c` matches `a.b.c` but not `a.b.d.c`. Arrays are traversed; each element uses a `[i]`-suffixed path that path rules will never match (arrays are safe by default — only Sensitive wrappers or pattern rules can redact array elements).

View File

@@ -0,0 +1,89 @@
# sampling
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The sampling module provides pluggable policies for deciding which log records to emit and which to drop. Reducing log volume is essential in high-throughput services where emitting every debug record would overwhelm storage. Three sampler implementations cover the common cases: `HeadSampler` for deterministic trace-ID-based rate limiting, `TailSampler` for buffered post-hoc decisions (useful for keeping error traces in full while dropping healthy ones), and `RuleSampler` for predicate-based routing.
The sampling decision happens before redaction and sink dispatch, so dropped records never touch sensitive-field handling or I/O.
## Features
- `HeadSampler` — deterministic FNV-1a hash of `trace_id` against a `rate ∈ [0,1]`; records without a `trace_id` are always kept
- `TailSampler` — buffers records per `trace_id`; flushes the buffer to a user-supplied `decide()` callback when it reaches `bufferSize`
- `RuleSampler` — ordered predicate list; first matching rule wins; configurable fallback
- `SampleAllSampler` / `SampleNoneSampler` — trivial always-keep / always-drop sentinels
- `createSampler(policy)` factory from the `SamplingPolicy` discriminated union
- Property-based tests verify `HeadSampler` determinism across 500 trace IDs
## Usage: Basic Example
```ts
import { HeadSampler } from "@suaveplan/logger/sampling";
const sampler = new HeadSampler({ rate: 0.1 }); // keep 10% of traces
const decision = sampler.decide(record); // "keep" or "drop"
```
## Usage: Advanced Example
```ts
import { createSampler } from "@suaveplan/logger/sampling";
import type { SamplingPolicy } from "@suaveplan/logger/sampling";
// Keep all errors; drop 90% of info; drop everything else
const policy: SamplingPolicy = {
kind: "rule",
options: {
rules: [
{ match: (r) => r.level === "error", decision: "keep" },
{ match: (r) => r.level === "info", decision: Math.random() < 0.1 ? "keep" : "drop" },
],
fallback: "drop",
},
};
const sampler = createSampler(policy);
```
## API Reference
### `class HeadSampler implements Sampler`
**Constructor**: `new HeadSampler({ rate: number })`
Deterministic: same `trace_id` always yields the same decision. Records without `trace_id` are always kept.
**Throws** `RangeError` if `rate < 0 || rate > 1`.
### `class TailSampler implements Sampler`
**Constructor**: `new TailSampler({ bufferSize?, decide })`
| Option | Default | Description |
|--------|---------|-------------|
| `bufferSize` | 100 | Maximum records to buffer per trace before forcing a decision |
| `decide` | — | `(records: LogRecord[]) => SamplingDecision` |
#### `.flush(traceId?): SamplingDecision`
Forces a decision on the buffer for `traceId` (or all buffers if omitted).
### `class RuleSampler implements Sampler`
**Constructor**: `new RuleSampler({ rules, fallback? })`
Applies `rules` in order; returns `fallback` (default `"keep"`) when no rule matches.
### `createSampler(policy: SamplingPolicy): Sampler`
Factory that instantiates the appropriate sampler from the `SamplingPolicy` union.
### `type SamplingPolicy`
Discriminated union: `{ kind: "all" } | { kind: "none" } | { kind: "head"; options } | { kind: "tail"; options } | { kind: "rule"; options }`.
## Implementation Notes
`HeadSampler` uses FNV-1a 32-bit hashing for determinism — the same trace ID always maps to the same fraction of the `[0, 0xFFFFFFFF]` range. This ensures all log records from a single trace are either kept or dropped together, which is the key invariant of head-based sampling. The distribution is uniform enough for practical sampling rates but not cryptographically secure — use only for sampling decisions, not security.

View File

@@ -0,0 +1,97 @@
# buffered-sink
## Purpose
`BufferedSink` is an in-memory queuing wrapper around any `Sink` implementation. It decouples the hot path of log record production from the potentially slower I/O performed by the underlying sink, allowing burst writes to be absorbed without blocking callers. The buffer is bounded by a configurable `maxQueueSize`, and when that limit is reached the sink applies one of two overflow policies: silently drop the excess record (`"drop"`) or drain the head record inline before pushing the new one (`"block"`). The `"block"` policy never parks writers in an external waiter list; it always makes forward progress by awaiting `inner.write` of the oldest record before accepting the new one. This eliminates the deadlock hazard of the original "wait until someone calls `flush()` externally" design.
This class is designed for high-throughput server processes where log writes must not stall request handling, and for test harnesses where controlling when records reach the underlying sink is essential for deterministic assertions.
## Features
- Wraps any `Sink` and delegates `write`, `flush`, and `close` calls
- Bounded in-memory queue with configurable `maxQueueSize` (default 1000)
- Two overflow policies: `"drop"` silently discards excess records; `"block"` suspends the caller via a `Promise` until queue capacity frees up
- `dropped` property tracks cumulative records discarded under the `"drop"` policy
- `flush(signal?)` drains the queue sequentially, forwarding each record to the inner sink; throws `LoggerError(LOGGER.FLUSH)` if the provided `AbortSignal` fires mid-drain
- `close()` flushes all pending records then delegates to `inner.close()` if implemented
- Re-entrant flush guard — concurrent `flush()` calls short-circuit rather than double-draining
- `"block"` policy drains inline on overflow — no external `flush()` is required for `write()` to make forward progress, eliminating the deadlock hazard of waiter-list designs
- Constructor rejects non-positive `maxQueueSize` with `LoggerError(LOGGER.CONFIG)`
## Usage: Basic Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const json = new JsonSink({ writer: (line) => process.stdout.write(line + "\n") });
const buffer = new BufferedSink({
sink: json,
maxQueueSize: 500,
overflowPolicy: "drop",
});
// Write a record — enqueued immediately, not yet flushed to stdout
await buffer.write(record);
// Drain all pending records to the underlying JsonSink
await buffer.flush();
```
## Usage: Advanced Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new BufferedSink({
sink: new JsonSink({ writer: appendToFile }),
maxQueueSize: 2000,
overflowPolicy: "block", // back-pressure: callers await capacity
});
// Graceful shutdown with a 5-second deadline
process.on("SIGTERM", async () => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5_000);
try {
await sink.flush(ac.signal);
} catch {
// AbortSignal fired — some records may remain in the queue
console.error(`Dropped ${sink.dropped} records during shutdown`);
} finally {
clearTimeout(timer);
await sink.close();
}
});
```
## API Reference
### `class BufferedSink implements Sink`
**Constructor**: `new BufferedSink(options: BufferedSinkOptions)`
| Option | Type | Default | Description |
|---|---|---|---|
| `sink` | `Sink` | required | The underlying sink that receives flushed records |
| `maxQueueSize` | `number` | `1000` | Maximum queued records before overflow policy triggers |
| `overflowPolicy` | `"drop" \| "block"` | `"drop"` | Action when queue is full |
**Properties**
- `dropped: number` — Cumulative count of records discarded under the `"drop"` policy. Does not reset between flushes.
**Methods**
- `write(record: LogRecord): Promise<void>` — Enqueue a record. Under `"drop"` policy, records beyond `maxQueueSize` are silently counted and discarded. Under `"block"` policy, when the queue is at capacity `write()` first awaits `inner.write` of the head record, then pushes; this ensures forward progress without requiring an external `flush()` and rules out the deadlock that a parked-writer design would expose if no one ever flushed.
- `flush(signal?: AbortSignal): Promise<void>` — Drain the entire queue by forwarding records to the inner sink in FIFO order. Throws `LoggerError(LOGGER.FLUSH)` if the signal is aborted. Throws `LoggerError(LOGGER.SINK)` if the inner sink's `write` rejects. Re-entrant calls return immediately.
- `close(): Promise<void>` — Flush then close the inner sink (if it implements `close()`).
## Implementation Notes
`BufferedSink` uses a simple array queue (`shift()` for FIFO) rather than a ring buffer. For typical logging workloads (hundreds of records per second) the GC pressure is negligible. The `"block"` policy makes forward progress inline (drains the head before pushing) so concurrent overflowing writers serialise on `inner.write` rather than queuing in a waiter list — back-pressure is expressed naturally through awaited promises. The design favours simplicity over micro-optimisation since the bottleneck is always the inner sink's I/O, not the queue operations.
There is no background flush timer — `BufferedSink` is purely demand-driven. If you need periodic auto-flush, start your own `setInterval` that calls `flush()` and clears it on shutdown.

View File

@@ -0,0 +1,89 @@
# console-sink
## Purpose
`ConsoleSink` is the human-readable terminal output sink for `@suaveplan/logger`. It formats each `LogRecord` into a single coloured, prefixed line and dispatches it to the appropriate `console` method (`console.debug`, `console.info`, `console.warn`, or `console.error`) so that log output appears at the correct severity level in browser DevTools and Node.js terminals alike.
The sink is intentionally synchronous — `write` returns `void`, not `Promise<void>` — because `console.*` is itself synchronous and any async wrapper would introduce misleading latency for a purely I/O-synchronous operation.
## Features
- ANSI colour codes per log level: cyan for `debug`, green for `info`, yellow for `warn`, red for `error`
- Optional ISO timestamp prefix, dimmed for contrast in colour mode
- Optional `#tag` rendering when the log record carries `ctx.__tags`
- Optional compact JSON metadata block printed below the message line
- All formatting options can be toggled at construction time or by mutating public fields after construction
- Delegates to the correct `console` method for each log level, preserving browser DevTools severity grouping
- No external dependencies beyond the `Sink` interface and `LogRecord` types
## Usage: Basic Example
```ts
import { ConsoleSink } from "@suaveplan/logger/sink";
const sink = new ConsoleSink(); // colors + timestamp enabled by default
sink.write({
ts: new Date().toISOString(),
level: "info",
msg: "Server started",
ctx: {},
});
// Output: [2024-01-01T00:00:00.000Z] [INFO] Server started
```
## Usage: Advanced Example
```ts
import { ConsoleSink } from "@suaveplan/logger/sink";
import { ConsoleLogger } from "@suaveplan/logger/console";
// Plain text for CI environments (no ANSI codes in log files)
const sink = new ConsoleSink({
colors: false,
timestamp: true,
includeMetadata: true,
includeTags: true,
});
const logger = new ConsoleLogger({ sink });
logger.info("Request completed", {
__tags: ["http", "perf"],
duration: 42,
status: 200,
});
// Output:
// [2024-01-01T00:00:00.000Z] [INFO] [#http #perf] Request completed
// {
// "__tags": ["http", "perf"],
// "duration": 42,
// "status": 200
// }
```
## API Reference
### `class ConsoleSink implements Sink`
**Constructor**: `new ConsoleSink(options?: ConsoleSinkOptions)`
All options are also exposed as mutable public fields so they can be toggled after construction (e.g., to disable colours when a terminal is detected as non-interactive).
| Option / Field | Type | Default | Description |
|---|---|---|---|
| `colors` | `boolean` | `true` | Wrap level and timestamp tokens in ANSI colour escapes |
| `timestamp` | `boolean` | `true` | Prefix each line with the record's `ts` value |
| `includeMetadata` | `boolean` | `false` | Append `ctx` as a formatted JSON block below the message |
| `includeTags` | `boolean` | `true` | Inline `ctx.__tags` values as `#tag` tokens |
**Methods**
- `write(record: LogRecord): void` — Format and print the record synchronously. Never throws; `console.*` errors (which are vanishingly rare) surface as uncaught exceptions in the host environment.
## Implementation Notes
ANSI escape codes are defined as a small static map keyed by `LogLevel` plus `"reset"` and `"dim"` sentinels. The formatter builds a `parts: string[]` array and joins once at the end to minimise string concatenation allocations per record.
The sink checks `console[method]` at call time rather than caching the reference at construction, which means it respects any `console` mocking that test frameworks install. This is deliberate — `ConsoleSink` is commonly used in test environments where `console.error` is wrapped to assert that no unexpected errors are logged.
Colour mode does not auto-detect `process.stdout.isTTY`; that decision is left to the caller (typically the factory or the logger bootstrap) so the sink itself remains environment-agnostic and testable without process environment manipulation.

View File

@@ -0,0 +1,80 @@
# json-sink
## Purpose
`JsonSink` is the machine-readable structured output sink for `@suaveplan/logger`. It serialises each `LogRecord` to a single JSON line and passes it to a configurable `writer` callback — defaulting to `process.stdout.write` — producing newline-delimited JSON (NDJSON) that is directly ingestible by log aggregators such as Fluentd, Logstash, Loki, or Datadog.
The design is deliberately thin: one serialisation step, one write call, no buffering, no formatting decisions. Buffering is the responsibility of `BufferedSink`; formatting decisions belong to the caller. `JsonSink` is the composable primitive that sits at the bottom of a sink stack.
## Features
- Serialises `LogRecord` to a compact JSON line via `JSON.stringify`
- Pluggable `writer` callback — defaults to `process.stdout.write` with a trailing newline
- Throws a structured `LoggerError(LOGGER.SERIALIZE)` if serialisation fails (e.g., circular references in `ctx`)
- Synchronous `write` — no buffering, no internal state; each call is independent
- Trivially composable with `BufferedSink` for async, bounded queuing
- No external runtime dependencies beyond the `LogRecord` type and `LoggerError`
## Usage: Basic Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new JsonSink();
// Writes: {"ts":"2024-01-01T00:00:00.000Z","level":"info","msg":"hello","ctx":{}}\n
sink.write({
ts: new Date().toISOString(),
level: "info",
msg: "hello",
ctx: {},
});
```
## Usage: Advanced Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
import { BufferedSink } from "@suaveplan/logger/sink";
import { createWriteStream } from "node:fs";
// Write NDJSON to a rotating log file
const stream = createWriteStream("/var/log/app/app.ndjson", { flags: "a" });
const json = new JsonSink({
writer: (line) => stream.write(line + "\n"),
});
// Wrap in a buffer to absorb bursts
const buffered = new BufferedSink({ sink: json, maxQueueSize: 2000 });
// Forward records from the logger to the buffered sink
logger.addSink(buffered);
// Drain and close on process exit
process.on("SIGTERM", async () => {
await buffered.close();
stream.end();
});
```
## API Reference
### `class JsonSink implements Sink`
**Constructor**: `new JsonSink(options?: JsonSinkOptions)`
| Option | Type | Default | Description |
|---|---|---|---|
| `writer` | `(line: string) => void` | `process.stdout.write(line + "\n")` | Receives each serialised JSON line |
**Methods**
- `write(record: LogRecord): void` — Serialise the record and invoke the writer callback. Throws `LoggerError` with code `LOGGER.SERIALIZE` if `JSON.stringify` throws (e.g., due to a circular reference inside `ctx`).
## Implementation Notes
The default `writer` uses `process.stdout.write` rather than `console.log` to avoid the extra newline that `console.log` appends to its own output and to prevent interference with any `console` mocking installed by test frameworks.
`JsonSink` does not implement `flush` or `close` — it has no internal buffer to drain. When wrapped in `BufferedSink`, those lifecycle methods are handled by the buffer layer. This clean separation of concerns keeps `JsonSink` trivially testable with a synchronous `writer` stub.
Because `JSON.stringify` can throw on circular structures, `JsonSink` wraps the call in a try/catch and re-throws as a typed `LoggerError` so callers can distinguish serialisation failures from other sink errors without inspecting error message strings.

View File

@@ -0,0 +1,90 @@
# sink
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The sink layer defines the `Sink` interface — the pluggable output primitive for `@suaveplan/logger` — and provides three concrete implementations: `ConsoleSink` for human-readable terminal output during development, `JsonSink` for line-delimited JSON (NDJSON) suitable for log aggregators, and `BufferedSink` for asynchronous, bounded-queue writes with configurable overflow policy and `AbortSignal`-aware flush. The `MultiSink` utility routes a single record to multiple sinks in parallel.
By decoupling the logger from any specific I/O implementation, the same `AbstractLogger` can write to a terminal, a file, a remote endpoint, or an in-memory buffer without code changes — only the sink configuration differs.
## Features
- `Sink` interface: `write(record)`, optional `flush(signal?)`, optional `close()`
- `ConsoleSink` — ANSI colour per level, ISO timestamp prefix, optional tag and metadata formatting
- `JsonSink` — serialises `LogRecord` to a single JSON line, passes to a pluggable `writer` callback (default: `process.stdout.write`)
- `BufferedSink` — bounded in-memory queue; `"drop"` policy silently discards overflow; `"block"` policy awaits drain; `flush(AbortSignal?)` drains with cancellation support
- `MultiSink` — fan-out to multiple sinks concurrently
- `isSink()` type guard; `flushSink()` / `closeSink()` helpers that no-op for sinks lacking those methods
## Usage: Basic Example
```ts
import { JsonSink } from "@suaveplan/logger/sink";
const sink = new JsonSink({ writer: (line) => process.stdout.write(line + "\n") });
sink.write(record); // {"ts":"...","level":"info","msg":"hello","ctx":{}}
```
## Usage: Advanced Example
```ts
import { BufferedSink } from "@suaveplan/logger/sink";
import { JsonSink } from "@suaveplan/logger/sink";
const json = new JsonSink({ writer: appendToFile });
const buf = new BufferedSink({
sink: json,
maxQueueSize: 500,
overflowPolicy: "drop",
});
// In a long-running process, flush on shutdown:
process.on("SIGTERM", async () => {
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000); // 5 s timeout
await buf.flush(ac.signal);
await buf.close();
});
```
## API Reference
### `interface Sink`
```ts
interface Sink {
write(record: LogRecord): void | Promise<void>;
flush?(signal?: AbortSignal): Promise<void>;
close?(): Promise<void>;
}
```
### `class ConsoleSink implements Sink`
Options: `{ colors?, timestamp?, includeMetadata?, includeTags? }`.
### `class JsonSink implements Sink`
Options: `{ writer? }`. Throws `LoggerError(LOGGER.SERIALIZE)` if `JSON.stringify` fails.
### `class BufferedSink implements Sink`
Options: `{ sink, maxQueueSize?, overflowPolicy? }`. Property `.dropped` counts overflow-dropped records.
### `class MultiSink implements Sink`
**Constructor**: `new MultiSink(sinks: Sink[])`. Writes and flushes all sinks concurrently.
### `isSink(value): value is Sink`
Type guard.
### `flushSink(sink, signal?) / closeSink(sink)`
Helpers that call `sink.flush(signal)` / `sink.close()` only if the method exists.
## Implementation Notes
`BufferedSink` does not start a background interval — it flushes lazily on `flush()` or `close()`. This keeps the class dependency-free (no `Clock` needed) and avoids surprises in test environments. The `"block"` overflow policy uses a simple Promise queue to suspend `write()` callers until the next flush iteration frees capacity.

View File

@@ -0,0 +1,100 @@
# storage-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StorageLogger` persists log batches to any key-value store that implements the `StorageInterface` contract (set/get/list/delete with `Uint8Array` values). It extends `AbstractLogger` so all enterprise features (redaction, sampling, OTel, clock injection, metrics) run before any storage write. Log entries are buffered in memory and flushed either automatically on a configurable interval, when the buffer reaches `maxBatchSize`, or manually via `flush()`. Retrieved logs can be filtered by date range and limited in count.
## Features
- Buffered writes: accumulate entries in-memory, flush as a single JSON batch keyed by timestamp
- Auto-flush on interval (configurable, default 5 s) or manual `flush(signal?)`
- `maxBatchSize` threshold triggers an immediate async flush
- `getLogs({ limit?, startDate?, endDate?, signal? })` retrieves and deserialises all stored batches
- `clearLogs(olderThan?)` removes batches (optionally filtered by timestamp)
- `destroy()` stops the auto-flush timer and flushes remaining buffer
- Injectable `StorageInterface` — works with any KV store (Cloudflare KV, IndexedDB shims, in-memory)
## Usage: Basic Example
```ts
import { StorageLogger } from "@suaveplan/logger/storage";
const logger = new StorageLogger(cloudflareKV, { level: "info" });
logger.info("Request received", { path: "/api/users" });
// Automatically flushed every 5 s
```
## Usage: Advanced Example
```ts
import { StorageLogger } from "@suaveplan/logger/storage";
const logger = new StorageLogger(storage, {
level: "debug",
storagePrefix: "app:logs",
maxBatchSize: 50,
flushInterval: 10_000,
autoFlush: true,
});
// Manual flush with abort support:
const ac = new AbortController();
setTimeout(() => ac.abort(), 3_000);
await logger.flush(ac.signal);
// Retrieve last 100 error logs in last hour:
const recent = await logger.getLogs({
limit: 100,
startDate: new Date(Date.now() - 3_600_000),
});
// Teardown:
await logger.destroy();
```
## API Reference
### `class StorageLogger<TMetadata> extends AbstractLogger<TMetadata>`
**Constructor**: `new StorageLogger(storage, config?)`
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `storagePrefix` | `string` | `"logs"` | Key prefix for stored batches |
| `maxBatchSize` | `number` | `100` | Triggers immediate flush when reached |
| `flushInterval` | `number` | `5000` | Auto-flush interval in ms |
| `autoFlush` | `boolean` | `true` | Enable interval-based flushing |
#### `flush(signal?: AbortSignal): Promise<void>`
Writes the current buffer to storage. Aborts if `signal` is already aborted before the write.
#### `getLogs(options?): Promise<LogEntry<TMetadata>[]>`
Options: `{ limit?, startDate?, endDate?, signal? }`. Returns entries sorted newest-first.
#### `clearLogs(olderThan?: Date): Promise<void>`
Deletes all stored batches, or only those older than `olderThan`.
#### `destroy(): Promise<void>`
Clears the auto-flush timer and flushes remaining buffer.
### `interface StorageInterface`
```ts
interface StorageInterface {
set(key: string, value: Uint8Array): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
list(options?: { prefix?: string }): Promise<{ keys: string[] }>;
delete(key: string): Promise<void>;
}
```
## Implementation Notes
Each flush writes a JSON array of `LogEntry` objects under the key `${prefix}:${ISO-timestamp}:${uuid}`. The UUID prevents key collisions when multiple instances flush at the same millisecond. `extractTimestamp` reverse-engineers the timestamp by finding the last `:` separator, which is robust because the ISO timestamp itself contains colons. On flush failure, the buffered entries are re-queued at the front of the buffer so no records are silently lost.

View File

@@ -0,0 +1,99 @@
# @suaveplan/opentelemetry - OpenTelemetry SDK setup and configuration helpers: tracer/meter/logger providers, resource detection, exporter configuration, and sampling strategies
## Summary
OpenTelemetry SDK setup and configuration helpers for Suaveplan. Provides batteries-included OTel initialization helpers, resource detection, ergonomic span/metrics wrappers, W3C trace-context propagation utilities, and a no-op SDK façade that works without any heavyweight `@opentelemetry/sdk-*` packages.
<!-- begin:auto-docs -->
## Documentation tree
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
<!-- end:auto-docs --> - [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
## Packages
| Module | Path | Description |
|--------|------|-------------|
| `errors` | [src/errors/opentelemetry-error.md](src/errors/opentelemetry-error.md) | Error class and code registry |
| `sdk` | [src/core/sdk/noop-sdk.md](src/core/sdk/noop-sdk.md) | `OtelSdk` interface and `NoopSdk` implementation |
| `sdk-factory` | [src/core/sdk/sdk-factory.md](src/core/sdk/sdk-factory.md) | `createSdk` — validated config → SDK instance |
| `span-utils` | [src/utils/span-utils.md](src/utils/span-utils.md) | `withSpan`, `recordException`, `addAttributes`, `getTraceContext` |
| `metrics-utils` | [src/utils/metrics-utils.md](src/utils/metrics-utils.md) | `createCounter`, `createHistogram`, `createGauge`, `recordDuration` |
| `resource` | [src/utils/resource.md](src/utils/resource.md) | `createResource` — OTel resource attribute builder |
| `propagation` | [src/core/propagation/propagation.md](src/core/propagation/propagation.md) | W3C traceparent inject/extract/parse/build |
## Quick Start
```ts
import { createSdk } from "@suaveplan/opentelemetry/sdk";
import { withSpan } from "@suaveplan/opentelemetry/span-utils";
import { createCounter, recordDuration } from "@suaveplan/opentelemetry/metrics-utils";
const sdk = createSdk({
serviceName: "my-service",
serviceVersion: "1.0.0",
environment: "production",
});
await sdk.start();
const tracer = sdk.getTracer("my-service");
const meter = sdk.getMeter("my-service");
const requestCounter = createCounter(meter, "http.request.total", "Total HTTP requests");
const latency = createHistogram(meter, "http.request.duration", "Request latency", "ms");
async function handleRequest(req: Request) {
return withSpan(tracer, "http.handle", async (span) => {
span.setAttribute("http.method", req.method);
const start = Date.now();
const response = await processRequest(req);
requestCounter.add(1, { "http.status": response.status });
recordDuration(latency, start, { "http.route": "/api" });
return response;
});
}
// Graceful shutdown
process.on("SIGTERM", () => sdk.shutdown());
```
## Subpath Exports
```ts
import { ... } from "@suaveplan/opentelemetry"; // all exports
import { createSdk, NoopSdk } from "@suaveplan/opentelemetry/sdk";
import { withSpan, recordException } from "@suaveplan/opentelemetry/span-utils";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry/metrics-utils";
import { injectTraceContext, parseTraceParent } from "@suaveplan/opentelemetry/propagation";
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
```

View File

@@ -0,0 +1,108 @@
# propagation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides helpers for W3C Trace Context header injection and extraction, enabling distributed tracing across service boundaries. When a service makes an outgoing HTTP call it injects the current trace context into the request headers; when a service receives an inbound request it extracts the upstream trace context from the headers so subsequent spans are automatically parented to the upstream caller's span.
These utilities wrap `@opentelemetry/api`'s global propagator, meaning they respect whatever propagator implementation (or no-op) the application has registered — library code using these helpers does not need to know which propagation format is in use.
## Features
- `injectTraceContext` — adds `traceparent`/`tracestate` headers to an outgoing headers map
- `extractTraceContext` — parses incoming headers and activates the remote span context
- `buildTraceParent` — formats a W3C `traceparent` header string from parts
- `parseTraceParent` — validates and parses a `traceparent` header into structured fields
- `getActiveSpanIds` — returns the active span's trace ID and span ID for manual use
## Usage: Basic Example
```ts
import { injectTraceContext, extractTraceContext } from "@suaveplan/opentelemetry/propagation";
// Outgoing HTTP call
const headers = injectTraceContext({ "content-type": "application/json" });
await fetch("https://downstream.service/api", { headers });
// Incoming HTTP request handler
extractTraceContext(req.headers as Record<string, string>);
// All spans created after this call will be children of the upstream span
```
## Usage: Advanced Example
```ts
import {
buildTraceParent,
parseTraceParent,
injectTraceContext,
} from "@suaveplan/opentelemetry/propagation";
// Build a traceparent manually for synthetic test traces
const traceparent = buildTraceParent(
"4bf92f3577b34da6a3ce929d0e0e4736",
"00f067aa0ba902b7",
1 // sampled
);
// "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
// Parse and validate a received header
const parsed = parseTraceParent(traceparent);
if (parsed !== null) {
console.log(`trace=${parsed.traceId} span=${parsed.spanId} sampled=${parsed.flags & 1}`);
}
// null for invalid headers
parseTraceParent("not-a-header"); // null
parseTraceParent("01-invalid-version-format"); // null
```
## API Reference
### `injectTraceContext(headers)`
```ts
function injectTraceContext(headers: Record<string, string>): Record<string, string>
```
Injects the active W3C trace context into `headers` via the global propagator. Returns the same object with headers added. Safe to call with no active span — the propagator no-ops gracefully.
### `extractTraceContext(headers)`
```ts
function extractTraceContext(headers: Record<string, string>): void
```
Extracts a remote span context from `headers` using the global propagator. No-op when no recognisable headers are present.
### `buildTraceParent(traceId, spanId, flags?)`
```ts
function buildTraceParent(traceId: string, spanId: string, flags?: number): string
```
Formats a W3C `traceparent` header: `"00-{traceId}-{spanId}-{flags:02x}"`. `flags` defaults to `1` (sampled bit set).
### `parseTraceParent(header)`
```ts
function parseTraceParent(header: string): { traceId: string; spanId: string; flags: number } | null
```
Parses and validates a `traceparent` header. Returns `null` for any invalid format, including wrong version byte, wrong lengths, or non-hex characters.
### `getActiveSpanIds()`
```ts
function getActiveSpanIds(): { traceId?: string; spanId?: string }
```
Returns the currently active span's `traceId` and `spanId`. Returns an empty object when no span is active.
## Implementation Notes
`injectTraceContext` and `extractTraceContext` use custom `TextMapSetter` / `TextMapGetter` implementations that operate directly on `Record<string, string>` objects rather than requiring a specific carrier type. This avoids a dependency on `@opentelemetry/core`'s `defaultTextMapGetter`/`defaultTextMapSetter`.
`parseTraceParent` uses a strict regex (`/^00-.../`) that requires version `00` — the only currently defined version in the W3C spec. Future spec versions would need explicit handling. The `TRACE_ID_RE` and `SPAN_ID_RE` guards after the regex match are redundant for inputs that passed the outer regex but are kept for defence-in-depth in case the outer pattern is ever loosened.

View File

@@ -0,0 +1,96 @@
# noop-sdk
## Purpose
Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter lookups to `@opentelemetry/api`'s built-in no-op providers. It also exports the `OtelSdk` interface itself, which is the abstraction callers program against regardless of which concrete implementation they use.
Having a no-op default means library packages can instrument their code with spans and metrics without forcing a specific OpenTelemetry SDK on their consumers. Applications that want real telemetry simply register an SDK before `start()` is called; libraries that don't need it continue working unchanged with zero overhead.
## Features
- `OtelSdk` interface — the contract for all SDK implementations in this package
- `NoopSdk` class — fully functional no-op implementation; start/shutdown are immediate no-ops
- `getTracer` / `getMeter` — delegate to `@opentelemetry/api` global providers (no-op by default)
- Lifecycle tracking via `isStarted()` — useful for guard checks and health probes
- Zero external dependencies beyond `@opentelemetry/api`
## Usage: Basic Example
```ts
import { NoopSdk } from "@suaveplan/opentelemetry/sdk";
const sdk = new NoopSdk();
await sdk.start();
const tracer = sdk.getTracer("my-service");
// Spans created with this tracer are silently discarded
await sdk.shutdown();
```
## Usage: Advanced Example
Injecting `NoopSdk` in tests while using a real SDK in production:
```ts
import type { OtelSdk } from "@suaveplan/opentelemetry/sdk";
import { NoopSdk } from "@suaveplan/opentelemetry/sdk";
class PaymentService {
constructor(private readonly sdk: OtelSdk) {}
async charge(amount: number): Promise<void> {
const tracer = this.sdk.getTracer("payment-service");
const span = tracer.startSpan("payment.charge");
try {
await processCharge(amount);
} finally {
span.end();
}
}
}
// In tests:
const svc = new PaymentService(new NoopSdk());
// In production (with a real SDK implementation):
// const realSdk = createSdk({ serviceName: "payment-service", ... });
// const svc = new PaymentService(realSdk);
```
## API Reference
### `OtelSdk` interface
```ts
interface OtelSdk {
start(): Promise<void>;
shutdown(): Promise<void>;
isStarted(): boolean;
getTracer(name: string, version?: string): Tracer;
getMeter(name: string, version?: string): Meter;
}
```
The contract all SDK implementations satisfy.
### `NoopSdk`
```ts
class NoopSdk implements OtelSdk {
start(): Promise<void>;
shutdown(): Promise<void>;
isStarted(): boolean;
getTracer(name: string, version?: string): Tracer;
getMeter(name: string, version?: string): Meter;
}
```
- `start()` — sets `isStarted()` to `true` and resolves immediately
- `shutdown()` — sets `isStarted()` to `false` and resolves immediately
- `getTracer(name, version?)` — returns `trace.getTracer(name, version)` from the `@opentelemetry/api` global
- `getMeter(name, version?)` — returns `metrics.getMeter(name, version)` from the `@opentelemetry/api` global
## Implementation Notes
The `started` field is `protected` rather than `private` to allow subclasses (e.g. a real SDK wrapper that extends `NoopSdk`) to read and modify it. This follows the project's preference for `protected` over `private`. `getTracer` and `getMeter` are not guarded by `isStarted()` by design — the no-op API already handles calls made before registration silently, and guarding would add overhead in the hot path without benefit.

View File

@@ -0,0 +1,87 @@
# sdk-factory
## Purpose
Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a validated `OtelConfig`. It performs Zod schema validation before any SDK construction, so callers get a clear, structured error if the configuration is malformed rather than a cryptic runtime failure deep inside an SDK constructor.
The factory intentionally returns a `NoopSdk` today. This is not a limitation — it is a deliberate separation of concerns: this package is responsible for OTel configuration helpers and ergonomic wrappers, not for pulling in heavyweight `@opentelemetry/sdk-*` packages. Applications that need real telemetry install the SDK packages themselves and either extend `NoopSdk` or register providers directly.
## Features
- Validates the full `OtelConfig` structure with Zod before constructing the SDK
- Throws a typed `OpenTelemetryError` with `OTEL.INVALID_CONFIG` and structured issue context on validation failure
- Development-mode config logging via `console.debug` when `NODE_ENV === "development"`
- Returns an `OtelSdk`-typed value — callers are decoupled from the concrete implementation
## Usage: Basic Example
```ts
import { createSdk } from "@suaveplan/opentelemetry/sdk";
const sdk = createSdk({ serviceName: "my-service" });
await sdk.start();
const tracer = sdk.getTracer("my-service");
```
## Usage: Advanced Example
```ts
import { createSdk } from "@suaveplan/opentelemetry/sdk";
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
// Full config with traces, metrics, and logs
const sdk = createSdk({
serviceName: "payment-service",
serviceVersion: "2.0.0",
environment: "staging",
traces: {
exporter: "otlp-http",
exporterUrl: "https://otel.internal.example.com/v1/traces",
sampler: "trace-id-ratio",
sampleRate: 0.1,
propagators: ["w3c-tracecontext", "baggage"],
batchSize: 512,
flushInterval: 5000,
},
metrics: {
exporter: "otlp-http",
exporterUrl: "https://otel.internal.example.com/v1/metrics",
exportIntervalMs: 30000,
},
logs: {
exporter: "otlp-http",
exporterUrl: "https://otel.internal.example.com/v1/logs",
},
});
// Invalid config — throws OpenTelemetryError
try {
createSdk({ serviceName: "" }); // empty string fails min(1)
} catch (err) {
if (err instanceof OpenTelemetryError && err.code === OTEL_CODES.INVALID_CONFIG) {
console.error("Config invalid:", err.context?.issues);
}
}
```
## API Reference
### `createSdk(config)`
```ts
function createSdk(config: OtelConfig): OtelSdk
```
Validates `config` with `OtelConfigSchema` and returns an `OtelSdk` instance.
- `config` — must satisfy the `OtelConfig` shape; see `otel-config.schema.ts` for full validation rules
- **Returns** an `OtelSdk` (currently `NoopSdk`)
- **Throws** `OpenTelemetryError` with code `OTEL.INVALID_CONFIG` when Zod validation fails; `err.context.issues` contains an array of `{ path, message }` objects
## Implementation Notes
The factory uses `OtelConfigSchema.safeParse` (not `.parse`) so validation failures are caught locally and re-thrown as `OpenTelemetryError` instances rather than Zod errors. This ensures callers always get typed errors from this package regardless of which validation library is in use.
Development logging is gated behind both `typeof process !== "undefined"` (safe in edge runtimes) and `process.env.NODE_ENV === "development"` (never logs in production or test). The logged object is `result.data` — the parsed and normalised output, not the raw input, so any Zod transformations are reflected.
The `NoopSdk` return is intentional and documented as a design contract rather than a TODO: applications that need real telemetry register their own providers and optionally wrap or replace the returned instance. This keeps `@suaveplan/opentelemetry` free of `@opentelemetry/sdk-*` dependencies.

View File

@@ -0,0 +1,101 @@
# opentelemetry-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable error class and code registry for all `@suaveplan/opentelemetry` failures. Every error thrown by this package is an `OpenTelemetryError` instance with a `code` drawn from `OTEL_CODES`, enabling callers to branch on machine-readable codes rather than string-matching error messages.
The code registry follows the `defineCodes` convention shared across the project: a frozen record mapping short reason keys to fully-qualified dot-separated strings (`OTEL.<REASON>`), narrowing the `TCode` type parameter so TypeScript knows the exact union at compile time.
## Features
- Four stable error codes covering the full SDK lifecycle: initialization, shutdown, configuration, and state guard
- Extends `SuaveplanError` for cause-chain traversal, wire-format serialization, OTel span-event adapter, and redaction support inherited from `@suaveplan/error`
- Fully narrowed `TCode` type — `instanceof OpenTelemetryError` narrows `.code` to `OtelCode`
- Frozen code registry prevents accidental mutation
## Usage: Basic Example
```ts
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
throw new OpenTelemetryError(
OTEL_CODES.INIT_FAILED,
"opentelemetry.init_failed"
);
```
## Usage: Advanced Example
```ts
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
try {
await provider.start();
} catch (cause) {
throw new OpenTelemetryError(
OTEL_CODES.INIT_FAILED,
"opentelemetry.init_failed",
{
cause: cause instanceof Error ? cause : new Error(String(cause)),
context: { provider: "otlp-http", endpoint: "https://otel.example.com" },
}
);
}
```
Catching the code:
```ts
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
try {
sdk.getTracer("my-service");
} catch (err) {
if (err instanceof OpenTelemetryError && err.code === OTEL_CODES.NOT_STARTED) {
console.warn("SDK not yet started — call sdk.start() first");
}
}
```
## API Reference
### `OTEL_CODES`
```ts
const OTEL_CODES: {
readonly INIT_FAILED: "OTEL.INIT_FAILED";
readonly SHUTDOWN_FAILED: "OTEL.SHUTDOWN_FAILED";
readonly INVALID_CONFIG: "OTEL.INVALID_CONFIG";
readonly NOT_STARTED: "OTEL.NOT_STARTED";
};
```
Frozen record mapping short reason keys to fully-qualified `OTEL.*` code strings.
### `OtelCode`
```ts
type OtelCode = "OTEL.INIT_FAILED" | "OTEL.SHUTDOWN_FAILED" | "OTEL.INVALID_CONFIG" | "OTEL.NOT_STARTED";
```
Union of all valid OTel error codes.
### `OpenTelemetryError`
```ts
class OpenTelemetryError extends SuaveplanError<OtelCode> {
constructor(code: OtelCode, messageKey: string, options?: SuaveplanErrorOptions): void;
}
```
- `code` — one of the `OTEL_CODES` values; available as `.code` on the thrown instance
- `messageKey` — i18n resolver key (falls back to the key itself when no resolver is registered)
- `options.cause` — original error to attach as cause
- `options.context` — arbitrary key/value bag for structured logging
## Implementation Notes
The constructor is marked with a biome suppress comment for `noUselessConstructor` because the sole purpose is to widen the `protected` base constructor to `public` — a required pattern across all `@suaveplan/*` error subclasses. The base `SuaveplanError` sets `this.name = new.target.name` automatically, so `error.name` will be `"OpenTelemetryError"` without needing a manual assignment.

View File

@@ -0,0 +1,159 @@
# otel-config.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the Zod schemas and inferred TypeScript types that define the complete OpenTelemetry SDK configuration contract for `@suaveplan/opentelemetry`. All runtime configuration objects are validated against these schemas before they reach any SDK lifecycle code, ensuring that schema violations surface as structured `OpenTelemetryError` instances rather than silent misbehavior or runtime crashes.
The schemas serve as the single source of truth for what constitutes a valid `OtelConfig`. Any change to the accepted configuration shape is made here first, and all downstream code (e.g. `createSdk`, test fixtures) derives from these exports.
## Features
- **`ExporterTypeSchema`** — enum of the four supported exporter backends: `console`, `otlp-http`, `otlp-grpc`, and `noop`.
- **`SamplerTypeSchema`** — enum of four trace-sampling strategies: `always-on`, `always-off`, `trace-id-ratio`, and `parent-based`.
- **`PropagatorTypeSchema`** — enum of four context-propagation formats: `w3c-tracecontext`, `baggage`, `b3`, and `b3-multi`.
- **`TracesConfigSchema`** — optional per-signal object covering exporter, URL, sampler type and rate, propagators list, batch size, and flush interval.
- **`MetricsConfigSchema`** — optional metrics signal object covering exporter, URL, and export interval.
- **`LogsConfigSchema`** — optional logs signal object covering exporter and URL.
- **`OtelConfigSchema`** — top-level schema requiring `serviceName` and allowing optional `serviceVersion`, `environment`, and per-signal sub-configs.
- **Inferred TypeScript types** — all schemas export a corresponding `z.infer<>` type alias so callers can use fully-typed config objects without re-declaring shapes.
## Usage: Basic Example
```ts
import { OtelConfigSchema } from "@suaveplan/opentelemetry";
const result = OtelConfigSchema.safeParse({
serviceName: "my-service",
serviceVersion: "1.0.0",
environment: "production",
});
if (!result.success) {
console.error(result.error.issues);
} else {
const config = result.data; // typed as OtelConfig
console.log(config.serviceName); // "my-service"
}
```
## Usage: Advanced Example
Using the schema to validate a rich configuration that covers all three signals, then feeding the validated data into `createSdk`:
```ts
import { createSdk, OtelConfigSchema, type OtelConfig } from "@suaveplan/opentelemetry";
const raw: unknown = {
serviceName: "payment-service",
serviceVersion: "2.3.1",
environment: "staging",
traces: {
exporter: "otlp-http",
exporterUrl: "https://otel.example.com/v1/traces",
sampler: "trace-id-ratio",
sampleRate: 0.25,
propagators: ["w3c-tracecontext", "baggage"],
batchSize: 512,
flushInterval: 5000,
},
metrics: {
exporter: "otlp-http",
exporterUrl: "https://otel.example.com/v1/metrics",
exportIntervalMs: 30000,
},
logs: {
exporter: "console",
},
};
const parsed = OtelConfigSchema.parse(raw); // throws ZodError on failure
const sdk = createSdk(parsed as OtelConfig);
await sdk.start();
```
You can also validate individual sub-schemas independently:
```ts
import { TracesConfigSchema } from "@suaveplan/opentelemetry";
const tracesResult = TracesConfigSchema.safeParse({
exporter: "noop",
sampler: "always-off",
});
// tracesResult.success === true
```
## API Reference
### `ExporterTypeSchema`
`z.ZodEnum` — validates that a value is one of `"console" | "otlp-http" | "otlp-grpc" | "noop"`.
### `SamplerTypeSchema`
`z.ZodEnum` — validates that a value is one of `"always-on" | "always-off" | "trace-id-ratio" | "parent-based"`.
### `PropagatorTypeSchema`
`z.ZodEnum` — validates that a value is one of `"w3c-tracecontext" | "baggage" | "b3" | "b3-multi"`.
### `TracesConfigSchema`
`z.ZodObject` — all fields optional:
- `exporter?: ExporterType`
- `exporterUrl?: string` (validated as URL)
- `sampler?: SamplerType`
- `sampleRate?: number` (0.0–1.0 inclusive)
- `propagators?: PropagatorType[]`
- `batchSize?: number` (positive integer)
- `flushInterval?: number` (positive integer, milliseconds)
### `MetricsConfigSchema`
`z.ZodObject` — all fields optional:
- `exporter?: ExporterType`
- `exporterUrl?: string` (validated as URL)
- `exportIntervalMs?: number` (positive integer, milliseconds)
### `LogsConfigSchema`
`z.ZodObject` — all fields optional:
- `exporter?: ExporterType`
- `exporterUrl?: string` (validated as URL)
### `OtelConfigSchema`
`z.ZodObject` — top-level config schema:
- `serviceName: string` (required, minimum length 1)
- `serviceVersion?: string`
- `environment?: string`
- `traces?: TracesConfig`
- `metrics?: MetricsConfig`
- `logs?: LogsConfig`
### Type Aliases
- `OtelConfig` — `z.infer<typeof OtelConfigSchema>`
- `TracesConfig` — `z.infer<typeof TracesConfigSchema>`
- `MetricsConfig` — `z.infer<typeof MetricsConfigSchema>`
- `LogsConfig` — `z.infer<typeof LogsConfigSchema>`
- `ExporterType` — `z.infer<typeof ExporterTypeSchema>`
- `SamplerType` — `z.infer<typeof SamplerTypeSchema>`
- `PropagatorType` — `z.infer<typeof PropagatorTypeSchema>`
## Implementation Notes
The schema file is intentionally kept free of business logic — it contains only Zod definitions and their `z.infer<>` type exports. Validation errors from `OtelConfigSchema.safeParse` are surfaced to callers by `createSdk`, which maps them into `OpenTelemetryError` instances with the `OTEL.INVALID_CONFIG` code.
The `sampleRate` field uses `z.number().min(0).max(1)` with both bounds inclusive so that `0.0` (never sample) and `1.0` (always sample) are valid — these boundary values are important for feature-flag-driven sampling.
All URL fields use `z.string().url()` rather than a looser regex to ensure that only well-formed absolute URLs are accepted, preventing misconfigured exporter endpoints from reaching the network layer at runtime.
The enum schemas (`ExporterTypeSchema`, `SamplerTypeSchema`, `PropagatorTypeSchema`) are exported as named constants rather than inlined inside the parent schemas so that they can be reused independently for validation of partial configs or for driving UI option lists without pulling in the full parent schema.

View File

@@ -0,0 +1,108 @@
# metrics-utils
## Purpose
Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and ergonomic call signatures. These utilities reduce boilerplate at call sites, enforce consistent instrument creation patterns, and make the most common metrics pattern — recording a duration — a single-line operation.
Like all utilities in this package, these depend only on `@opentelemetry/api`, so they work equally well with any SDK implementation or the built-in no-op fallback used in tests and library code.
## Features
- `createCounter` — monotonically increasing counter for totals (requests, errors, events)
- `createHistogram` — value distribution tracking for latencies and sizes (supports unit string)
- `createGauge` — observable gauge for current-value snapshots (memory, queue depth)
- `recordDuration` — records elapsed `Date.now() - startMs` on a histogram in one call
## Usage: Basic Example
```ts
import { createCounter, createHistogram, recordDuration } from "@suaveplan/opentelemetry/metrics-utils";
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("my-service");
const errors = createCounter(meter, "app.errors", "Total application errors");
errors.add(1, { "error.type": "validation" });
const latency = createHistogram(meter, "app.request.duration", "Request duration", "ms");
const start = Date.now();
await handleRequest();
recordDuration(latency, start, { "http.route": "/api/users" });
```
## Usage: Advanced Example
```ts
import { createGauge, createHistogram, createCounter } from "@suaveplan/opentelemetry/metrics-utils";
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("worker-service", "1.0.0");
// Observable gauge for current queue depth (polled at collection time)
const queueGauge = createGauge(meter, "worker.queue.depth", "Current items in the work queue");
let currentQueueDepth = 0;
queueGauge.addCallback((result) => {
result.observe(currentQueueDepth, { "queue.name": "orders" });
});
// Histogram with explicit unit
const processingTime = createHistogram(
meter,
"worker.job.processing_time",
"Time spent processing each job",
"ms"
);
// Counter for tracking retries
const retryCounter = createCounter(meter, "worker.job.retries", "Total job retry attempts");
async function processJob(job: Job) {
const start = Date.now();
try {
await job.execute();
} catch {
retryCounter.add(1, { "job.type": job.type });
throw;
} finally {
recordDuration(processingTime, start, { "job.type": job.type });
}
}
```
## API Reference
### `createCounter(meter, name, description?)`
```ts
function createCounter(meter: Meter, name: string, description?: string): Counter
```
Creates a synchronous, monotonically increasing counter. Call `.add(value, attributes?)` to increment.
### `createHistogram(meter, name, description?, unit?)`
```ts
function createHistogram(meter: Meter, name: string, description?: string, unit?: string): Histogram
```
Creates a synchronous histogram instrument. Call `.record(value, attributes?)` to record an observation. `unit` follows the OTel unit convention (e.g. `"ms"`, `"By"`, `"1"`).
### `createGauge(meter, name, description?)`
```ts
function createGauge(meter: Meter, name: string, description?: string): ObservableGauge
```
Creates an asynchronous observable gauge. Register a callback via `.addCallback(fn)` to supply the current value at collection time.
### `recordDuration(histogram, startMs, attributes?)`
```ts
function recordDuration(histogram: Histogram, startMs: number, attributes?: Attributes): void
```
Records `Date.now() - startMs` on `histogram`. `startMs` should be captured with `Date.now()` immediately before the operation being measured.
## Implementation Notes
All factory functions pass the `description` to the underlying API even when it is `undefined` — the API ignores undefined values gracefully. The decision to use `createObservableGauge` (asynchronous) over `createGauge` (synchronous, upDownCounter-style) aligns with the OTel recommendation that current-value snapshots should use the observable API so the SDK can poll them at collection time rather than storing every intermediate value. `recordDuration` always uses `Date.now()` as the wall-clock source; for high-resolution measurement use `performance.now()` and convert to milliseconds before passing as `startMs`.

View File

@@ -0,0 +1,66 @@
# resource
## Purpose
Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes are attached to every span, metric, and log record produced by the SDK, providing the correlation anchor that ties telemetry signals back to a specific deployed service instance.
This utility abstracts the mapping from `OtelConfig` fields to OTEL Semantic Conventions keys (`service.name`, `service.version`, `deployment.environment`), so callers never have to remember which convention strings to use.
## Features
- Maps `serviceName` → `service.name` (always present)
- Maps `serviceVersion` → `service.version` (omitted when undefined)
- Maps `environment` → `deployment.environment` (omitted when undefined)
- Returns a frozen `Attributes` object — safe to share across SDK, tests, and exporters
- Zero side effects; pure function
## Usage: Basic Example
```ts
import { createResource } from "@suaveplan/opentelemetry/span-utils";
const attrs = createResource({ serviceName: "payment-service" });
// { "service.name": "payment-service" }
```
## Usage: Advanced Example
```ts
import { createResource } from "@suaveplan/opentelemetry/span-utils";
const attrs = createResource({
serviceName: "payment-service",
serviceVersion: "2.1.0",
environment: "production",
});
// {
// "service.name": "payment-service",
// "service.version": "2.1.0",
// "deployment.environment": "production",
// }
// Verify immutability
Object.assign(attrs, { extra: "ignored" }); // throws in strict mode (frozen)
```
## API Reference
### `createResource(config)`
```ts
function createResource(
config: Pick<OtelConfig, "serviceName" | "serviceVersion" | "environment">
): Attributes
```
Builds and freezes an OTel resource attribute map.
- `config.serviceName` — required; mapped to `service.name`
- `config.serviceVersion` — optional; mapped to `service.version` when present
- `config.environment` — optional; mapped to `deployment.environment` when present
**Returns** a frozen `Attributes` object (`Record<string, AttributeValue>`).
## Implementation Notes
The object is frozen with `Object.freeze` to prevent accidental mutation after construction. Optional fields are only included when they are not `undefined` — this matches the OpenTelemetry convention where absent attributes should be omitted rather than set to empty strings. The function does not accept a full `OtelConfig` in order to remain decoupled from signal-level configuration and avoid pulling in Zod validation at call time.

View File

@@ -0,0 +1,102 @@
# span-utils
## Purpose
Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finally/recordException/setStatus boilerplate in every traced function, callers use `withSpan` to declaratively wrap work in a span with correct error handling built in.
These utilities depend only on `@opentelemetry/api` and are therefore compatible with any OTel SDK implementation (or the no-op fallback), making them safe to import in library code that should not force a specific SDK on its consumers.
## Features
- `withSpan` — wraps an async function in an active span; records exceptions and ends the span unconditionally
- `recordException` — normalizes any thrown value to an Error and records it with ERROR status
- `addAttributes` — sets a bulk `Attributes` map on a span in a single call
- `getTraceContext` — extracts `traceId`, `spanId`, and `traceFlags` from the currently active span
## Usage: Basic Example
```ts
import { withSpan } from "@suaveplan/opentelemetry/span-utils";
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("my-service");
const result = await withSpan(tracer, "user.fetch", async (span) => {
span.setAttribute("user.id", userId);
return await db.findUser(userId);
});
```
## Usage: Advanced Example
```ts
import { withSpan, addAttributes, getTraceContext } from "@suaveplan/opentelemetry/span-utils";
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("payment-service", "1.0.0");
async function processPayment(orderId: string, amount: number) {
return withSpan(
tracer,
"payment.process",
async (span) => {
addAttributes(span, {
"payment.order_id": orderId,
"payment.amount": amount,
"payment.currency": "USD",
});
const result = await chargeGateway(orderId, amount);
const ctx = getTraceContext();
console.log("trace:", ctx.traceId, "span:", ctx.spanId);
return result;
},
{ attributes: { "payment.initial": true } }
);
}
```
## API Reference
### `withSpan(tracer, name, fn, options?)`
```ts
function withSpan<T>(
tracer: Tracer,
name: string,
fn: (span: Span) => Promise<T>,
options?: { attributes?: Attributes }
): Promise<T>
```
Starts an active child span, calls `fn` with it, ends the span in `finally`, and records any exception + sets ERROR status before re-throwing.
### `recordException(span, error)`
```ts
function recordException(span: Span, error: unknown): void
```
Records `error` on `span` as a span event and sets `SpanStatusCode.ERROR`. Non-Error values are wrapped in `new Error(String(error))` before recording.
### `addAttributes(span, attrs)`
```ts
function addAttributes(span: Span, attrs: Attributes): void
```
Sets each key/value pair from `attrs` on the span. `undefined` values are skipped.
### `getTraceContext()`
```ts
function getTraceContext(): { traceId?: string; spanId?: string; traceFlags?: number }
```
Returns the active span's context fields. All fields are `undefined` when no span is active.
## Implementation Notes
`withSpan` uses `tracer.startActiveSpan` so that child spans created inside `fn` are automatically parented to this span via the context API — no manual context propagation required. The `finally` block ensures `span.end()` is always called even when `fn` throws, which is the single most common OTel correctness bug in hand-written tracing code. `recordException` follows the OTel spec by calling the API's built-in `span.recordException` rather than manually building the event, ensuring the `exception.type`, `exception.message`, and `exception.stacktrace` attributes are populated correctly.