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

159 lines
6.7 KiB
Markdown

# 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
{
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:
```ts
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 | 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.
### 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:
```ts
{
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.