Files
suaveplan/openspec/archive/2026-04-27-logger-silent-failure-fix/specs/logging/spec.md
2026-05-27 23:29:53 +01:00

6.6 KiB

Spec Delta — logging

MODIFIED Requirements

LG-11 — Metrics

AbstractLogger MUST expose getMetrics() returning:

{
  totalLogs: number;
  logsByLevel: Record<LogLevel, number>;
  droppedBySampling: number;
  droppedByBackpressure: number;
  /** Records lost because a sink threw or rejected during dispatch. */
  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/conformance.spec-conformance.test.ts (sink-error counter scenarios) and src/abstract/abstract-logger.test.ts.

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"} + dropped_total{reason:"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/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).

ADDED Requirements

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

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

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