Files
2026-05-27 23:29:53 +01:00

6.7 KiB

Capability Spec: Logging

Package: @suaveplan/logger Version: 0.0.0 (genesis) Status: ADDED


ADDED Requirements

LG-01 — Log levels

The logger MUST support exactly four severity levels in ascending order: debug < info < warn < error. Messages below the configured minimum level are silently dropped. The level is runtime-configurable via setLevel(level).

LG-02 — Structured log record

Every emitted log record MUST conform to the LogRecord Zod schema:

{
  ts:        string;   // ISO-8601 UTC timestamp (from injected clock)
  level:     LogLevel; // "debug" | "info" | "warn" | "error"
  msg:       string;   // human-readable message
  ctx:       Record<string, unknown>; // merged context (tags, metadata)
  trace_id?: string;   // hex-encoded OTel trace ID (16 bytes)
  span_id?:  string;   // hex-encoded OTel span ID (8 bytes)
  error?:    unknown;  // serialized error payload (from @suaveplan/error toJSON)
}

LG-03 — Pluggable sinks

The logger MUST route each LogRecord through an ordered array of Sink instances. A Sink MUST implement:

interface Sink {
  write(record: LogRecord): void | Promise<void>;
  flush?(signal?: AbortSignal): Promise<void>;
  close?(): Promise<void>;
}

LG-04 — Console sink

ConsoleSink MUST produce human-readable output to console with:

  • ANSI colour-coding per level (configurable; off in non-TTY/CI)
  • ISO timestamp prefix (configurable)
  • Tags inline as #tag tokens
  • Metadata as a compact JSON suffix

LG-05 — JSON (NDJSON) sink

JsonSink MUST serialize each LogRecord as a single line of JSON terminated by \n and pass it to a pluggable WritableStream-compatible writer or (line: string) => void callback.

LG-06 — Buffered sink

BufferedSink MUST wrap any Sink with:

  • A configurable maximum queue depth (default: 1 000 records)
  • Two overflow policies: "drop" (silently discard; increment counter) and "block" (await drain before accepting next record)
  • flush(signal?: AbortSignal) that drains the queue, honouring the AbortSignal

LG-07 — Redaction engine

RedactionEngine MUST:

  • Accept a list of RedactionRule items specifying field paths (dot-notation, glob * wildcard) to replace with the string "[REDACTED]" before the record reaches any sink
  • Detect Sensitive<T> wrappers (from @suaveplan/error/redact) and redact them automatically
  • Never mutate the original context object — always return a deep-cloned, redacted copy
  • Apply redaction before OTel context attachment and sink dispatch

LG-08 — Sampling

The logger MUST support a pluggable SamplingPolicy:

Sampler Behaviour
HeadSampler Deterministic: hash trace_id against a rate ∈ [0,1]; no-op if no trace_id
TailSampler Buffer up to N records per trace; invoke user-supplied decide(records) callback and emit/drop the buffer
RuleSampler Ordered predicates `(record) => boolean

When a sampling policy returns "drop", the record is not passed to redaction or sinks but all OTel metrics are still incremented.

LG-09 — OpenTelemetry correlation

When @opentelemetry/api has an active span, getOtelTraceContext() MUST return { trace_id, span_id } as lowercase hex strings. When no span is active or @opentelemetry/api is not installed, it MUST return {} (no error thrown).

LG-10 — Injectable clock and RNG

AbstractLogger MUST accept optional clock: Clock (from @suaveplan/types) and rng: Rng (from @suaveplan/types) in its config. Defaults: Date for clock, crypto.getRandomValues for rng. No Date.now() or Math.random() at module scope.

LG-11 — Metrics

AbstractLogger MUST expose getMetrics() returning:

{
  totalLogs: number;
  logsByLevel: Record<LogLevel, number>;
  droppedBySampling: number;
  droppedByBackpressure: number;
  lastLogTime?: Date;
}

LG-12 — OTel instrumentation

For every log() call the implementation MUST:

  • Increment a logger.records_total counter (attributes: level)
  • Record a logger.record_size_bytes histogram (attributes: level)
  • Increment logger.dropped_total when a record is dropped by sampling or backpressure (attributes: reason: "sampling" | "backpressure")

All via @opentelemetry/api — no-op when no exporter is registered.

LG-13 — Structured errors

All errors thrown by @suaveplan/logger MUST be instances of LoggerError extends SuaveplanError with stable codes registered via defineCodes:

Code constant Trigger
LOGGER.SINK A sink's write() or flush() threw
LOGGER.SERIALIZE JSON serialisation of a log record failed
LOGGER.CONFIG Invalid configuration detected at construction time
LOGGER.FLUSH flush() aborted via AbortSignal

LG-14 — i18n / keyed messages

Log messages MUST be plain strings. Callers MAY pass a message key plus interpolation args as { key: string; args?: Record<string, unknown> } — the logger resolves them via the MessageResolver from @suaveplan/types if one is installed. Unresolved keys are emitted as-is.

LG-15 — Error bridge

installSuaveplanErrorInterceptor(logger?) MUST register an interceptor with @suaveplan/error that routes every SuaveplanError to the logger at the severity dictated by err.severity. The registration MUST be idempotent — re-calling returns the existing uninstall function.

LG-16 — Factory

createLogger(config) MUST be an overloaded factory returning the concrete logger type (ConsoleLogger | StorageLogger | EventLogger) based on the type discriminant in config.

LG-17 — Init helper

initLogger(options?) MUST construct a ConsoleLogger (or accept a pre-built logger), wire the error bridge, and return { logger, uninstall }.

LG-18 — Property-based tests

The following MUST have fast-check property tests:

  • Redaction: ∀ record, rules → redacted record contains no unredacted sensitive fields
  • Sampling: HeadSampler is deterministic: same trace_id + rate always gives same decision
  • JSON round-trip: ∀ record → JSON.parse(JSON.stringify(record)) satisfies the LogRecord Zod schema

LG-19 — Benchmarks

src/__benchmarks__/logger.bench.ts MUST exercise the hot path (log() → redaction → sampling → JsonSink) with mitata and record throughput (records/s). CI regression gate: > 10% slowdown vs baseline fails.

LG-20 — Tree-shake safety

sideEffects: false MUST be set. A test importing only import type { Logger } from "@suaveplan/logger" MUST produce a zero-byte bundle (or type-only import). The concrete classes MUST be importable individually via subpath exports without pulling in the full bundle.