258 lines
14 KiB
Markdown
258 lines
14 KiB
Markdown
# 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
|
|
{
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
{
|
|
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`.
|