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
#tagtokens - 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 theAbortSignal
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
RedactionRuleitems 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_totalcounter (attributes:level) - Record a
logger.record_size_byteshistogram (attributes:level) - Increment
logger.dropped_totalwhen a record is dropped, with areasonattribute drawn from the closed set"sampling" | "backpressure" | "sink_error".sink_errorMUST 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 theLogRecordZod 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:
- Iterate sinks in registration order.
- Wrap each
sink.write(record)in an isolated try/catch (sync) and.catch(...)(async). A throw / rejection in sinkiMUST NOT prevent sinksi+1, …, n-1from being attempted. - On any sink failure: increment
droppedBySinkError, firelogger.dropped_total{reason:"sink_error"}, build aLoggerError(LOGGER.SINK)(withcauseset to the originalError, if any), and write a fallbackLogRecordtoconfig.fallbackSink. The fallback record MUST carry the serialisedLoggerErrorunderrecord.errorand include the failing sink's index underrecord.ctx.__sink_index. - The default
fallbackSinkMUST write viaconsole.error. dispatchToSinksMUST NOT throw out of thelog()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.