Files
suaveplan/openspec/specs/logging/spec.md
2026-05-27 23:29:53 +01:00

14 KiB

logging

Purpose

Foundation logging package — structured JSON output, level filtering, redaction, sampling, OTel correlation, and pluggable sinks. Provides AbstractLogger with concrete ConsoleLogger, StorageLogger, and EventLogger implementations, head/tail/rule sampling policies, and a global error-bridge that routes every SuaveplanError to the configured logger.

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

Verified by: src/abstract/abstract-logger.test.ts (level-filtering scenarios) AND src/record/log-record.test.ts.

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)
}

Verified by: src/record/log-record.test.ts.

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>;
}

Verified by: src/sink/sink.test.ts AND src/abstract/abstract-logger.test.ts (sink-dispatch scenarios).

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

Verified by: src/sink/sink.test.ts (ConsoleSink scenarios).

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.

Verified by: src/sink/sink.test.ts (JsonSink scenarios).

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" (drain head record inline before pushing — see LG-22)
  • flush(signal?: AbortSignal) that drains the queue, honouring the AbortSignal

Verified by: src/sink/sink.test.ts (BufferedSink scenarios) AND src/conformance.spec-conformance.test.ts (LG-22 forward-progress).

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 (see LG-27)
  • Never mutate the original context object — always return a deep-cloned, redacted copy
  • Apply redaction before OTel context attachment and sink dispatch

Verified by: src/redaction/redaction.test.ts AND src/abstract/abstract-logger.test.ts (redaction integration).

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 | undefined; first match wins; fallback to SampleAll

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

Verified by: src/sampling/sampling.test.ts AND src/abstract/abstract-logger.test.ts (sampler integration) AND src/abstract/abstract-logger.spec-conformance.test.ts (drop-counter).

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

Verified by: src/otel/otel-context.test.ts.

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.

Verified by: src/abstract/abstract-logger.test.ts (clock/rng injection scenarios).

LG-11 — Metrics

AbstractLogger MUST expose getMetrics() returning:

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

droppedByBackpressure is reserved for downstream queue saturation (e.g. BufferedSink overflow under policy: "drop"). Sink throws and rejections during dispatchToSinks MUST increment droppedBySinkError instead — never droppedByBackpressure.

Verified by: src/abstract/abstract-logger.test.ts AND src/conformance.spec-conformance.test.ts (sink-error counter scenarios).

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, with a reason attribute drawn from the closed set "sampling" | "backpressure" | "sink_error". sink_error MUST be fired for sink throws and rejections caught by the dispatch path.

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

Verified by: src/abstract/abstract-logger.spec-conformance.test.ts (records_total + record_size_bytes + dropped_total{reason:"sampling" | "sink_error"}).

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() rejected or threw, OR BufferedSink.flush()'s inner write rejected. The dispatch path MUST surface this code through fallbackSink (it MUST NOT throw out of log()).
LOGGER.SERIALIZE JSON serialisation of a log record failed
LOGGER.CONFIG Invalid configuration detected at construction time, including BufferedSink maxQueueSize <= 0
LOGGER.FLUSH flush() aborted via AbortSignal

Verified by: src/errors/logger-error.test.ts AND src/conformance.spec-conformance.test.ts (LOGGER.SINK from dispatch + LOGGER.CONFIG from BufferedSink constructor) AND src/sink/sink.test.ts (LOGGER.FLUSH from BufferedSink.flush).

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.

Verified by: src/abstract/abstract-logger.test.ts (string-message MUST path; the optional { key, args } overload is forward-looking).

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.

Verified by: src/error-bridge/error-bridge.test.ts.

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.

Verified by: src/factory/factory.test.ts.

LG-17 — Init helper

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

Verified by: src/init/init.test.ts.

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

Verified by: src/redaction/redaction.test.ts, src/sampling/sampling.test.ts, and src/record/log-record.test.ts property suites.

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.

Verified by: src/__benchmarks__/logger.bench.ts.

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.

Verified by: src/abstract/abstract-logger.spec-conformance.test.ts (sideEffects + subpath-exports + dist-shape scenarios).

LG-21 — Sink dispatch isolation

AbstractLogger.dispatchToSinks(record) MUST:

  1. Iterate sinks in registration order.
  2. Wrap each sink.write(record) in an isolated try/catch (sync) and .catch(...) (async). A throw / rejection in sink i MUST NOT prevent sinks i+1, …, n-1 from being attempted.
  3. On any sink failure: increment droppedBySinkError, fire logger.dropped_total{reason:"sink_error"}, build a LoggerError(LOGGER.SINK) (with cause set to the original Error, if any), and write a fallback LogRecord to config.fallbackSink. The fallback record MUST carry the serialised LoggerError under record.error and include the failing sink's index under record.ctx.__sink_index.
  4. The default fallbackSink MUST write via console.error.
  5. dispatchToSinks MUST NOT throw out of the log() call.

Verified by: src/conformance.spec-conformance.test.ts (sync throw, async rejection, two-sink isolation, fallbackSink record shape, default console.error fallback, layered fallback failure).

LG-22 — BufferedSink no-deadlock contract

BufferedSink under overflowPolicy: "block" MUST make forward progress without requiring an external flush(). When the queue is already at maxQueueSize and write() is called, the implementation MUST drain the head record inline (await inner.write(head)) before pushing the new record. There MUST NOT be an external waiter list that depends on flush() for resolution.

The constructor MUST throw LoggerError(LOGGER.CONFIG) if maxQueueSize is not a positive integer.

Verified by: src/conformance.spec-conformance.test.ts (forward-progress without flush + invalid maxQueueSize + inline-drain failure wrapping) AND src/sink/sink.test.ts.

LG-23 — MultiSink fan-out semantics

MultiSink([a, b, …]) MUST dispatch each record to every wrapped sink in parallel via Promise.all. When composed under AbstractLogger, per-sink failures inherit the LG-21 contract: an inner sink throw / rejection inside the parallel fan-out is reported to the parent logger's fallbackSink and does not prevent the other inner sinks from receiving the record. flush() and close() similarly fan out in parallel and aggregate via Promise.all.

Verified by: src/conformance.spec-conformance.test.ts (two-sink parallel dispatch) AND src/sink/sink.test.ts (MultiSink scenarios).

LG-24 — Generic Logger surface

The exported Logger interface MUST be generic over metadata shape: Logger<TMetadata extends Record<string, unknown> = Record<string, unknown>>. Concrete loggers (ConsoleLogger, StorageLogger, EventLogger) and AbstractLogger MUST honour the same generic. The default parameter preserves the original LG-02 ctx: Record<string, unknown> clause for callers that don't specialise.

Verified by: src/conformance.spec-conformance.test.ts (typed-metadata scenario) AND src/abstract/abstract-logger.test.ts.

LG-25 — Concrete loggers

The package MUST export the following concrete loggers, each extending AbstractLogger:

Class writeLog behaviour
ConsoleLogger Routes LogEntry to console[level] (with optional formatting).
StorageLogger Persists each LogEntry to a StorageLike adapter.
EventLogger Publishes each LogEntry to an EventBusLike.

Each MUST be importable both from the package root and from a dedicated subpath export (./console, ./storage, ./event).

Verified by: src/console/console-logger.test.ts, src/storage/storage-logger.test.ts, src/event/event-logger.test.ts.

LG-26 — Lifecycle controls

AbstractLogger MUST expose enable(), disable(), isEnabled(), and resetMetrics() as part of the public surface. disable() suppresses every subsequent log() call (including sink dispatch and metrics). resetMetrics() returns the metrics struct to its zero state without affecting configuration.

Verified by: src/abstract/abstract-logger.test.ts (enable/disable + isEnabled + resetMetrics scenarios).

LG-27 — Sensitive<T> envelope

RedactionEngine MUST recognise values wrapped in the Sensitive<T> envelope from @suaveplan/error/redact (or the package's own re-export) and replace them with "[REDACTED]" in the redacted record. Detection MUST occur regardless of whether the field path matches a configured RedactionRule.

Verified by: src/redaction/redaction.test.ts.