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
#tagtokens - 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 theAbortSignal
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 - 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_totalcounter (attributes:level) - Record a
logger.record_size_byteshistogram (attributes:level) - Increment
logger.dropped_totalwhen 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 theLogRecordZod 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.